ai-robot-core/ai-service-admin/src/views/rag-lab/index.vue

885 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="rag-lab-page">
<div class="page-header">
<h1 class="page-title">RAG 实验室</h1>
<p class="page-desc">测试检索增强生成效果查看检索结果和 AI 响应</p>
</div>
<el-row :gutter="24">
<el-col :xs="24" :sm="24" :md="10" :lg="10">
<el-card shadow="hover" class="input-card">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<el-icon><Edit /></el-icon>
</div>
<span class="header-title">调试输入</span>
</div>
<el-switch
v-model="flowTestMode"
active-text="完整流程测试"
inactive-text="RAG 测试"
@change="handleModeChange"
/>
</div>
</template>
<el-form label-position="top">
<el-form-item label="查询 Query">
<el-input
v-model="query"
type="textarea"
:rows="4"
placeholder="输入测试问题..."
/>
</el-form-item>
<el-form-item label="知识库范围" v-if="!flowTestMode">
<el-select
v-model="kbIds"
multiple
placeholder="请选择知识库"
style="width: 100%"
:loading="kbLoading"
:teleported="true"
:popper-options="{ modifiers: [{ name: 'flip', enabled: true }, { name: 'preventOverflow', enabled: true }] }"
>
<el-option
v-for="kb in knowledgeBases"
:key="kb.id"
:label="`${kb.name} (${kb.documentCount}个文档)`"
:value="kb.id"
/>
</el-select>
</el-form-item>
<el-form-item label="LLM 模型" v-if="!flowTestMode">
<LLMSelector
v-model="llmProvider"
:providers="llmProviders"
:loading="llmLoading"
:current-provider="currentLLMProvider"
placeholder="使用默认配置"
clearable
@change="handleLLMChange"
/>
</el-form-item>
<template v-if="flowTestMode">
<el-divider content-position="left">流程配置</el-divider>
<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>
</template>
<template v-if="!flowTestMode">
<el-form-item label="参数配置">
<div class="param-item">
<span class="label">Top-K</span>
<el-input-number v-model="topK" :min="1" :max="10" />
</div>
<div class="param-item">
<span class="label">Score Threshold</span>
<el-slider
v-model="scoreThreshold"
:min="0"
:max="1"
:step="0.1"
show-input
/>
</div>
<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
type="primary"
block
@click="handleRun"
:loading="loading || streaming"
>
{{ flowTestMode ? '执行流程测试' : (streaming ? '生成中...' : '运行实验') }}
</el-button>
<el-button
v-if="streaming"
type="danger"
block
@click="handleStopStream"
style="margin-top: 10px;"
>
停止生成
</el-button>
</el-form>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="14" :lg="14">
<template v-if="flowTestMode">
<el-card shadow="hover" class="result-card" v-loading="loading">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper success">
<el-icon><Share /></el-icon>
</div>
<span class="header-title">执行流程 (12步)</span>
</div>
<div class="header-right" v-if="flowTestResult">
<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 v-else class="flow-result">
<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>
</el-card>
</template>
<template v-else>
<el-tabs v-model="activeTab" type="border-card" class="result-tabs">
<el-tab-pane label="召回片段" name="retrieval">
<div v-if="retrievalResults.length === 0" class="placeholder-text">
暂无实验数据
</div>
<div v-else class="result-list">
<el-card
v-for="(item, index) in retrievalResults"
:key="index"
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 class="result-content">{{ item.content }}</div>
</el-card>
</div>
</el-tab-pane>
<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-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Edit, Share } from '@element-plus/icons-vue'
import { runRagExperiment, createSSEConnection, type AIResponse, type RetrievalResult } from '@/api/rag'
import { getLLMProviders, getLLMConfig, type LLMProviderInfo } from '@/api/llm'
import { listKnowledgeBases } from '@/api/kb'
import { executeFlowTest, type FlowExecutionResponse, type FlowExecutionStep } from '@/api/flow-test'
import { useRagLabStore } from '@/stores/ragLab'
import { storeToRefs } from 'pinia'
import AIResponseViewer from '@/components/rag/AIResponseViewer.vue'
import StreamOutput from '@/components/rag/StreamOutput.vue'
import LLMSelector from '@/components/rag/LLMSelector.vue'
interface KnowledgeBase {
id: string
name: string
documentCount: number
}
const ragLabStore = useRagLabStore()
const {
query,
kbIds,
llmProvider,
topK,
scoreThreshold,
generateResponse,
streamOutput
} = storeToRefs(ragLabStore)
const loading = ref(false)
const kbLoading = ref(false)
const llmLoading = ref(false)
const streaming = ref(false)
const activeTab = ref('retrieval')
const knowledgeBases = ref<KnowledgeBase[]>([])
const llmProviders = ref<LLMProviderInfo[]>([])
const currentLLMProvider = ref('')
const retrievalResults = ref<RetrievalResult[]>([])
const finalPrompt = ref('')
const aiResponse = ref<AIResponse | null>(null)
const diagnostics = ref<any>(null)
const streamContent = ref('')
const streamError = ref<string | null>(null)
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
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 () => {
kbLoading.value = true
try {
const res: any = await listKnowledgeBases()
knowledgeBases.value = res.data || []
} catch (error) {
console.error('Failed to fetch knowledge bases:', error)
} finally {
kbLoading.value = false
}
}
const fetchLLMProviders = async () => {
llmLoading.value = true
try {
const [providersRes, configRes]: [any, any] = await Promise.all([
getLLMProviders(),
getLLMConfig()
])
llmProviders.value = providersRes?.providers || []
currentLLMProvider.value = configRes?.provider || ''
} catch (error) {
console.error('Failed to fetch LLM providers:', error)
} finally {
llmLoading.value = false
}
}
const handleLLMChange = (provider: LLMProviderInfo | undefined) => {
llmProvider.value = provider?.name || ''
}
const handleRun = async () => {
if (!query.value.trim()) {
ElMessage.warning('请输入查询 Query')
return
}
if (flowTestMode.value) {
await runFlowTest()
} else {
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
}
}
const runNormalExperiment = async () => {
loading.value = true
try {
const res: any = await runRagExperiment({
query: query.value,
kb_ids: kbIds.value,
top_k: topK.value,
score_threshold: scoreThreshold.value,
llm_provider: llmProvider.value || undefined,
generate_response: generateResponse.value
})
retrievalResults.value = res.retrieval_results || res.retrievalResults || []
finalPrompt.value = res.final_prompt || res.finalPrompt || ''
aiResponse.value = res.ai_response || res.aiResponse || null
diagnostics.value = res.diagnostics || null
totalLatencyMs.value = res.total_latency_ms || res.totalLatencyMs || 0
if (generateResponse.value) {
activeTab.value = 'ai-response'
} else {
activeTab.value = 'retrieval'
}
ElMessage.success('实验运行成功')
} catch (err: any) {
console.error(err)
ElMessage.error(err?.message || '实验运行失败')
} finally {
loading.value = false
}
}
const runStreamExperiment = async () => {
streaming.value = true
streamContent.value = ''
streamError.value = null
activeTab.value = 'ai-response'
abortStream = createSSEConnection(
'/admin/rag/experiments/stream',
{
query: query.value,
kb_ids: kbIds.value,
top_k: topK.value,
score_threshold: scoreThreshold.value,
llm_provider: llmProvider.value || undefined,
generate_response: true
},
(data: string) => {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'content') {
streamContent.value += parsed.content || ''
} else if (parsed.type === 'retrieval') {
retrievalResults.value = parsed.results || []
} else if (parsed.type === 'prompt') {
finalPrompt.value = parsed.prompt || ''
} else if (parsed.type === 'complete') {
aiResponse.value = {
content: streamContent.value,
prompt_tokens: parsed.prompt_tokens,
completion_tokens: parsed.completion_tokens,
total_tokens: parsed.total_tokens,
latency_ms: parsed.latency_ms,
model: parsed.model
}
totalLatencyMs.value = parsed.total_latency_ms || 0
streaming.value = false
ElMessage.success('生成完成')
} else if (parsed.type === 'error') {
streamError.value = parsed.message || '流式输出错误'
streaming.value = false
ElMessage.error(streamError.value || '未知错误')
}
} catch {
streamContent.value += data
}
},
(error: Error) => {
streaming.value = false
streamError.value = error.message
ElMessage.error(error.message)
},
() => {
streaming.value = false
}
)
}
const handleStopStream = () => {
if (abortStream) {
abortStream()
abortStream = null
}
streaming.value = false
ElMessage.info('已停止生成')
}
const clearResults = () => {
retrievalResults.value = []
finalPrompt.value = ''
aiResponse.value = null
diagnostics.value = null
streamContent.value = ''
streamError.value = null
totalLatencyMs.value = 0
}
onMounted(() => {
fetchKnowledgeBases()
fetchLLMProviders()
})
</script>
<style scoped>
.rag-lab-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;
}
.input-card {
animation: fadeInUp 0.5s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.icon-wrapper {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary-lighter);
border-radius: 10px;
color: var(--primary-color);
font-size: 18px;
}
.icon-wrapper.success {
background-color: #D1FAE5;
color: #059669;
}
.header-title {
font-size: 15px;
font-weight: 600;
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 {
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 16px;
}
.param-item .label {
width: 140px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
flex-shrink: 0;
}
.param-item :deep(.el-slider) {
flex: 1;
}
.result-tabs {
animation: fadeInUp 0.6s ease-out;
}
.result-tabs :deep(.el-tabs__header) {
border-radius: 12px 12px 0 0;
}
.placeholder-text {
color: var(--text-tertiary);
text-align: center;
padding: 60px 20px;
font-size: 14px;
}
.result-list {
max-height: 600px;
overflow-y: auto;
padding-right: 8px;
}
.result-card {
margin-bottom: 16px;
border: 1px solid var(--border-color);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.source {
font-size: 12px;
color: var(--text-tertiary);
}
.result-content {
font-size: 14px;
line-height: 1.7;
color: var(--text-primary);
}
.prompt-view,
.diagnostics-view {
background-color: var(--bg-tertiary);
padding: 16px;
border-radius: 10px;
max-height: 600px;
overflow-y: auto;
}
.prompt-view pre,
.diagnostics-view pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
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) {
.rag-lab-page {
padding: 16px;
}
.page-title {
font-size: 20px;
}
.param-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.param-item .label {
width: 100%;
}
.flow-config {
grid-template-columns: 1fr;
}
}
</style>