[AC-AISVC-02, AC-AISVC-16] 多个需求合并 #1

Merged
MerCry merged 45 commits from feature/prompt-unification-and-logging into main 2026-02-25 17:17:35 +00:00
27 changed files with 4830 additions and 435 deletions
Showing only changes of commit 4579159c0a - Show all commits

View File

@ -1,26 +1,63 @@
<template>
<div class="app-container">
<el-menu
:default-active="activeIndex"
class="el-menu-demo"
mode="horizontal"
router
>
<el-menu-item index="/dashboard">控制台</el-menu-item>
<el-menu-item index="/kb">知识库管理</el-menu-item>
<el-menu-item index="/rag-lab">RAG 实验室</el-menu-item>
<el-menu-item index="/monitoring">会话监控</el-menu-item>
<el-menu-item index="/admin/embedding">嵌入模型配置</el-menu-item>
<div class="flex-grow" />
<div class="tenant-selector">
<el-select v-model="currentTenantId" placeholder="选择租户" @change="handleTenantChange">
<el-option label="默认租户" value="default" />
<el-option label="租户 A" value="tenant_a" />
<el-option label="租户 B" value="tenant_b" />
</el-select>
<div class="app-wrapper">
<header class="app-header">
<div class="header-left">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<span class="logo-text">AI Robot</span>
</div>
<nav class="main-nav">
<router-link to="/dashboard" class="nav-item" :class="{ active: isActive('/dashboard') }">
<el-icon><Odometer /></el-icon>
<span>控制台</span>
</router-link>
<router-link to="/kb" class="nav-item" :class="{ active: isActive('/kb') }">
<el-icon><FolderOpened /></el-icon>
<span>知识库</span>
</router-link>
<router-link to="/rag-lab" class="nav-item" :class="{ active: isActive('/rag-lab') }">
<el-icon><Cpu /></el-icon>
<span>RAG 实验室</span>
</router-link>
<router-link to="/monitoring" class="nav-item" :class="{ active: isActive('/monitoring') }">
<el-icon><Monitor /></el-icon>
<span>会话监控</span>
</router-link>
<div class="nav-divider"></div>
<router-link to="/admin/embedding" class="nav-item" :class="{ active: isActive('/admin/embedding') }">
<el-icon><Connection /></el-icon>
<span>嵌入模型</span>
</router-link>
<router-link to="/admin/llm" class="nav-item" :class="{ active: isActive('/admin/llm') }">
<el-icon><ChatDotSquare /></el-icon>
<span>LLM 配置</span>
</router-link>
</nav>
</div>
</el-menu>
<router-view />
<div class="header-right">
<div class="tenant-selector">
<el-select
v-model="currentTenantId"
placeholder="选择租户"
size="default"
@change="handleTenantChange"
>
<el-option label="默认租户" value="default" />
<el-option label="租户 A" value="tenant_a" />
<el-option label="租户 B" value="tenant_b" />
</el-select>
</div>
</div>
</header>
<main class="app-main">
<router-view />
</main>
</div>
</template>
@ -28,28 +65,167 @@
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useTenantStore } from '@/stores/tenant'
import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare } from '@element-plus/icons-vue'
const route = useRoute()
const tenantStore = useTenantStore()
const activeIndex = computed(() => route.path)
const currentTenantId = ref(tenantStore.currentTenantId)
const isActive = (path: string) => {
return route.path === path || route.path.startsWith(path + '/')
}
const handleTenantChange = (val: string) => {
tenantStore.setTenant(val)
}
</script>
<style scoped>
.flex-grow {
flex-grow: 1;
.app-wrapper {
min-height: 100vh;
background-color: var(--bg-primary, #F8FAFC);
}
.tenant-selector {
.app-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
padding: 0 20px;
justify-content: space-between;
padding: 0 24px;
height: 60px;
background-color: var(--bg-secondary, #FFFFFF);
border-bottom: 1px solid var(--border-color, #E2E8F0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.app-container {
padding: 20px;
.header-left {
display: flex;
align-items: center;
gap: 32px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-color, #4F7CFF);
}
.logo-icon svg {
width: 28px;
height: 28px;
}
.logo-text {
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #1E293B);
letter-spacing: -0.5px;
}
.main-nav {
display: flex;
align-items: center;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary, #64748B);
text-decoration: none;
border-radius: 8px;
transition: all 0.2s ease;
}
.nav-item:hover {
color: var(--primary-color, #4F7CFF);
background-color: var(--primary-lighter, #E8EEFF);
}
.nav-item.active {
color: var(--primary-color, #4F7CFF);
background-color: var(--primary-lighter, #E8EEFF);
}
.nav-item .el-icon {
font-size: 16px;
}
.nav-divider {
width: 1px;
height: 20px;
margin: 0 8px;
background-color: var(--border-color, #E2E8F0);
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.tenant-selector {
min-width: 140px;
}
.tenant-selector :deep(.el-input__wrapper) {
background-color: var(--bg-tertiary, #F1F5F9);
border-color: transparent;
}
.tenant-selector :deep(.el-input__wrapper:hover) {
background-color: var(--bg-hover, #E2E8F0);
}
.app-main {
min-height: calc(100vh - 60px);
}
@media (max-width: 1024px) {
.app-header {
padding: 0 16px;
}
.header-left {
gap: 16px;
}
.main-nav {
gap: 2px;
}
.nav-item {
padding: 8px 10px;
}
.nav-item span {
display: none;
}
.nav-divider {
display: none;
}
}
@media (max-width: 640px) {
.logo-text {
display: none;
}
}
</style>

View File

@ -11,21 +11,21 @@ import type {
export function getLLMProviders(): Promise<LLMProvidersResponse> {
return request({
url: '/llm/providers',
url: '/admin/llm/providers',
method: 'get'
})
}
export function getLLMConfig(): Promise<LLMConfig> {
return request({
url: '/llm/config',
url: '/admin/llm/config',
method: 'get'
})
}
export function saveLLMConfig(data: LLMConfigUpdate): Promise<LLMConfigUpdateResponse> {
export function updateLLMConfig(data: LLMConfigUpdate): Promise<LLMConfigUpdateResponse> {
return request({
url: '/llm/config',
url: '/admin/llm/config',
method: 'put',
data
})
@ -33,7 +33,7 @@ export function saveLLMConfig(data: LLMConfigUpdate): Promise<LLMConfigUpdateRes
export function testLLM(data: LLMTestRequest): Promise<LLMTestResult> {
return request({
url: '/llm/test',
url: '/admin/llm/test',
method: 'post',
data
})

View File

@ -0,0 +1,219 @@
<template>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-width="labelWidth"
v-bind="$attrs"
>
<el-form-item
v-for="(field, key) in schemaProperties"
:key="key"
:label="field.title || key"
:prop="key"
>
<template #label>
<span>{{ field.title || key }}</span>
<el-tooltip v-if="field.description" :content="field.description" placement="top">
<el-icon class="ml-1 cursor-help"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-input
v-if="field.type === 'string'"
v-model="formData[key]"
:placeholder="`请输入${field.title || key}`"
clearable
:show-password="isPasswordField(key)"
/>
<el-input-number
v-else-if="field.type === 'integer' || field.type === 'number'"
v-model="formData[key]"
:placeholder="`请输入${field.title || key}`"
:min="field.minimum"
:max="field.maximum"
:step="field.type === 'number' ? 0.1 : 1"
:precision="field.type === 'number' ? 2 : 0"
controls-position="right"
class="w-full"
/>
<el-switch
v-else-if="field.type === 'boolean'"
v-model="formData[key]"
/>
<el-select
v-else-if="field.enum && field.enum.length > 0"
v-model="formData[key]"
:placeholder="`请选择${field.title || key}`"
clearable
class="w-full"
>
<el-option
v-for="option in field.enum"
:key="option"
:label="option"
:value="option"
/>
</el-select>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
export interface SchemaProperty {
type: string
title?: string
description?: string
default?: any
enum?: string[]
minimum?: number
maximum?: number
required?: boolean
}
export interface ConfigSchema {
type?: string
properties?: Record<string, SchemaProperty>
required?: string[]
}
const props = defineProps<{
schema: ConfigSchema
modelValue: Record<string, any>
labelWidth?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void
}>()
const formRef = ref<FormInstance>()
const formData = ref<Record<string, any>>({})
const schemaProperties = computed(() => {
return props.schema?.properties || {}
})
const requiredFields = computed(() => {
const required = props.schema?.required || []
const propsRequired = Object.entries(schemaProperties.value)
.filter(([, field]) => field.required)
.map(([key]) => key)
return [...new Set([...required, ...propsRequired])]
})
const formRules = computed<FormRules>(() => {
const rules: FormRules = {}
Object.entries(schemaProperties.value).forEach(([key, field]) => {
const fieldRules: any[] = []
if (requiredFields.value.includes(key)) {
fieldRules.push({
required: true,
message: `${field.title || key}不能为空`,
trigger: ['blur', 'change']
})
}
if (field.type === 'string' && field.minimum !== undefined) {
fieldRules.push({
min: field.minimum,
message: `${field.title || key}长度不能小于${field.minimum}`,
trigger: ['blur']
})
}
if (field.type === 'string' && field.maximum !== undefined) {
fieldRules.push({
max: field.maximum,
message: `${field.title || key}长度不能大于${field.maximum}`,
trigger: ['blur']
})
}
if (rules[key]) {
rules[key] = fieldRules
} else if (fieldRules.length > 0) {
rules[key] = fieldRules
}
})
return rules
})
const isPasswordField = (key: string): boolean => {
const lowerKey = key.toLowerCase()
return lowerKey.includes('password') || lowerKey.includes('secret') || lowerKey.includes('key') || lowerKey.includes('token')
}
const initFormData = () => {
const data: Record<string, any> = {}
Object.entries(schemaProperties.value).forEach(([key, field]) => {
if (props.modelValue && props.modelValue[key] !== undefined) {
data[key] = props.modelValue[key]
} else if (field.default !== undefined) {
data[key] = field.default
} else {
switch (field.type) {
case 'string':
data[key] = ''
break
case 'integer':
case 'number':
data[key] = field.minimum ?? 0
break
case 'boolean':
data[key] = false
break
default:
data[key] = null
}
}
})
formData.value = data
}
watch(
() => props.modelValue,
() => {
initFormData()
},
{ deep: true }
)
watch(
() => props.schema,
() => {
initFormData()
},
{ deep: true }
)
watch(
formData,
(val) => {
emit('update:modelValue', val)
},
{ deep: true }
)
onMounted(() => {
initFormData()
})
defineExpose({
validate: () => formRef.value?.validate(),
resetFields: () => formRef.value?.resetFields(),
clearValidate: () => formRef.value?.clearValidate()
})
</script>
<style scoped>
.w-full {
width: 100%;
}
.ml-1 {
margin-left: 4px;
}
.cursor-help {
cursor: help;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<el-select
:model-value="modelValue"
:loading="loading"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:teleported="true"
:popper-options="{ modifiers: [{ name: 'flip', enabled: true }, { name: 'preventOverflow', enabled: true }] }"
@update:model-value="handleChange"
>
<el-option
v-for="provider in providers"
:key="provider.name"
:label="provider.display_name"
:value="provider.name"
>
<div class="provider-option">
<span class="provider-name">{{ provider.display_name }}</span>
<span v-if="provider.description" class="provider-desc">{{ provider.description }}</span>
</div>
</el-option>
</el-select>
</template>
<script setup lang="ts">
export interface ProviderInfo {
name: string
display_name: string
description?: string
config_schema: Record<string, any>
}
const props = withDefaults(
defineProps<{
modelValue?: string
providers: ProviderInfo[]
loading?: boolean
disabled?: boolean
clearable?: boolean
placeholder?: string
}>(),
{
modelValue: '',
loading: false,
disabled: false,
clearable: false,
placeholder: '请选择提供者'
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [provider: ProviderInfo | undefined]
}>()
const handleChange = (value: string) => {
emit('update:modelValue', value)
const selectedProvider = props.providers.find((p) => p.name === value)
emit('change', selectedProvider)
}
</script>
<style scoped>
.provider-option {
display: flex;
flex-direction: column;
line-height: 1.5;
padding: 4px 0;
}
.provider-name {
font-weight: 500;
color: var(--text-primary);
}
.provider-desc {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
line-height: 1.4;
}
</style>

View File

@ -0,0 +1,523 @@
<template>
<el-card shadow="hover" class="test-panel">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<el-icon><Connection /></el-icon>
</div>
<span class="header-title">{{ title }}</span>
</div>
<el-tag v-if="testResult" :type="testResult.success ? 'success' : 'danger'" size="small" effect="dark">
{{ testResult.success ? '连接成功' : '连接失败' }}
</el-tag>
</div>
</template>
<div class="test-content">
<div class="test-form-section">
<div class="section-label">
<el-icon><Edit /></el-icon>
<span>{{ inputLabel }}</span>
</div>
<el-input
v-model="testInputValue"
type="textarea"
:rows="3"
:placeholder="inputPlaceholder"
clearable
class="test-textarea"
/>
<el-button
type="primary"
size="large"
:loading="loading"
:disabled="!canTest"
class="test-button"
@click="handleTest"
>
<el-icon v-if="!loading"><Connection /></el-icon>
{{ loading ? '测试中...' : '测试连接' }}
</el-button>
</div>
<transition name="result-fade">
<div v-if="testResult" class="test-result">
<el-divider />
<div v-if="testResult.success" class="success-result">
<div class="result-header">
<div class="success-icon">
<el-icon><CircleCheck /></el-icon>
</div>
<span class="result-title">{{ testResult.message || '连接成功' }}</span>
</div>
<div class="success-details">
<div v-if="testResult.dimension !== undefined" class="detail-card">
<div class="detail-icon">
<el-icon><Grid /></el-icon>
</div>
<div class="detail-info">
<span class="detail-label">向量维度</span>
<span class="detail-value">{{ testResult.dimension }}</span>
</div>
</div>
<div v-if="testResult.response" class="detail-card response-card">
<div class="detail-icon">
<el-icon><ChatDotRound /></el-icon>
</div>
<div class="detail-info">
<span class="detail-label">模型响应</span>
<span class="detail-value response-text">{{ testResult.response }}</span>
</div>
</div>
<div v-if="testResult.latency_ms" class="detail-card">
<div class="detail-icon">
<el-icon><Timer /></el-icon>
</div>
<div class="detail-info">
<span class="detail-label">响应延迟</span>
<span class="detail-value">{{ testResult.latency_ms.toFixed(2) }} ms</span>
</div>
</div>
<template v-if="showTokenStats && testResult.total_tokens !== undefined">
<div class="detail-card">
<div class="detail-icon">
<el-icon><Document /></el-icon>
</div>
<div class="detail-info">
<span class="detail-label">输入 Token</span>
<span class="detail-value">{{ testResult.prompt_tokens || 0 }}</span>
</div>
</div>
<div class="detail-card">
<div class="detail-icon">
<el-icon><EditPen /></el-icon>
</div>
<div class="detail-info">
<span class="detail-label">输出 Token</span>
<span class="detail-value">{{ testResult.completion_tokens || 0 }}</span>
</div>
</div>
<div class="detail-card">
<div class="detail-icon">
<el-icon><DataAnalysis /></el-icon>
</div>
<div class="detail-info">
<span class="detail-label"> Token</span>
<span class="detail-value">{{ testResult.total_tokens }}</span>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-result">
<div class="result-header">
<div class="error-icon">
<el-icon><CircleClose /></el-icon>
</div>
<span class="result-title error">连接失败</span>
</div>
<div class="error-message-box">
<p class="error-text">{{ testResult.error || '未知错误' }}</p>
</div>
<div class="troubleshooting">
<div class="troubleshoot-header">
<el-icon><Warning /></el-icon>
<span>排查建议</span>
</div>
<ul class="troubleshoot-list">
<li v-for="(tip, index) in troubleshootingTips" :key="index">
<el-icon class="list-icon"><Right /></el-icon>
{{ tip }}
</li>
</ul>
</div>
</div>
</div>
</transition>
</div>
</el-card>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Connection, Edit, CircleCheck, CircleClose, Timer, Grid, Warning, Right, ChatDotRound, Document, EditPen, DataAnalysis } from '@element-plus/icons-vue'
export interface TestResult {
success: boolean
dimension?: number
latency_ms?: number
message?: string
error?: string
response?: string
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
}
const props = withDefaults(
defineProps<{
testFn: (input?: string) => Promise<TestResult>
canTest?: boolean
title?: string
inputLabel?: string
inputPlaceholder?: string
showTokenStats?: boolean
}>(),
{
canTest: true,
title: '连接测试',
inputLabel: '测试输入',
inputPlaceholder: '请输入测试内容(可选,默认使用系统预设内容)',
showTokenStats: false
}
)
const loading = ref(false)
const testResult = ref<TestResult | null>(null)
const testInputValue = ref('')
const troubleshootingTips = computed(() => {
const tips: string[] = []
const error = testResult.value?.error?.toLowerCase() || ''
if (error.includes('timeout') || error.includes('超时')) {
tips.push('检查网络连接是否正常')
tips.push('确认服务地址是否正确且可访问')
tips.push('尝试增加请求超时时间')
} else if (error.includes('auth') || error.includes('unauthorized') || error.includes('认证') || error.includes('api key')) {
tips.push('检查 API Key 是否正确')
tips.push('确认 API Key 是否已过期或被禁用')
tips.push('验证 API Key 是否具有足够的权限')
} else if (error.includes('connection') || error.includes('连接') || error.includes('refused')) {
tips.push('确认服务地址host/port配置正确')
tips.push('检查目标服务是否正在运行')
tips.push('验证防火墙是否允许访问')
} else if (error.includes('model') || error.includes('模型')) {
tips.push('确认模型名称是否正确')
tips.push('检查模型是否已部署或可用')
tips.push('验证模型配置参数是否符合要求')
} else {
tips.push('检查所有配置参数是否正确')
tips.push('确认服务是否正常运行')
tips.push('查看服务端日志获取详细错误信息')
}
return tips
})
const handleTest = async () => {
loading.value = true
testResult.value = null
try {
const input = testInputValue.value?.trim() || undefined
const result = await props.testFn(input)
testResult.value = result
} catch (error: any) {
testResult.value = {
success: false,
error: error?.message || '请求失败,请检查网络连接'
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.test-panel {
border-radius: 16px;
border: none;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.test-panel:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
transform: translateY(-4px);
}
.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: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
color: #ffffff;
font-size: 20px;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.test-content {
padding: 8px 0;
}
.test-form-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #606266;
}
.section-label .el-icon {
color: #667eea;
}
.test-textarea {
border-radius: 10px;
}
.test-textarea :deep(.el-textarea__inner) {
border-radius: 10px;
border: 1px solid #dcdfe6;
transition: all 0.3s ease;
}
.test-textarea :deep(.el-textarea__inner:focus) {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.test-button {
align-self: flex-start;
border-radius: 10px;
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
transition: all 0.3s ease;
}
.test-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.test-button:disabled {
opacity: 0.6;
}
.test-result {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.result-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.success-icon,
.error-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 20px;
}
.success-icon {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
color: #ffffff;
}
.error-icon {
background: linear-gradient(135deg, #f56c6c 0%, #f89898 100%);
color: #ffffff;
}
.result-title {
font-size: 16px;
font-weight: 600;
color: #67c23a;
}
.result-title.error {
color: #f56c6c;
}
.success-details {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.detail-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%);
border-radius: 12px;
border: 1px solid #e1f3d8;
}
.response-card {
flex: 1;
min-width: 200px;
}
.detail-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
border-radius: 10px;
color: #ffffff;
font-size: 18px;
}
.detail-info {
display: flex;
flex-direction: column;
}
.detail-label {
font-size: 12px;
color: #909399;
}
.detail-value {
font-size: 18px;
font-weight: 700;
color: #303133;
}
.response-text {
font-size: 14px;
font-weight: 500;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-result {
animation: shake 0.5s ease-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.error-message-box {
padding: 14px 16px;
background: linear-gradient(135deg, #fef0f0 0%, #fde2e2 100%);
border-radius: 10px;
border-left: 3px solid #f56c6c;
margin-bottom: 16px;
}
.error-text {
margin: 0;
color: #f56c6c;
font-size: 14px;
line-height: 1.6;
}
.troubleshooting {
padding: 16px;
background: linear-gradient(135deg, #fdf6ec 0%, #faecd8 100%);
border-radius: 12px;
border: 1px solid #faecd8;
}
.troubleshoot-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-weight: 600;
color: #e6a23c;
}
.troubleshoot-list {
margin: 0;
padding: 0;
list-style: none;
}
.troubleshoot-list li {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
color: #606266;
font-size: 13px;
line-height: 1.6;
}
.list-icon {
margin-top: 4px;
color: #e6a23c;
font-size: 12px;
}
.result-fade-enter-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.result-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
}
.result-fade-enter-from {
opacity: 0;
transform: translateY(20px);
}
.result-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@ -5,6 +5,8 @@
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:teleported="true"
:popper-options="{ modifiers: [{ name: 'flip', enabled: true }, { name: 'preventOverflow', enabled: true }] }"
@update:model-value="handleChange"
>
<el-option
@ -58,16 +60,19 @@ const handleChange = (value: string) => {
.provider-option {
display: flex;
flex-direction: column;
line-height: 1.4;
line-height: 1.5;
padding: 4px 0;
}
.provider-name {
font-weight: 500;
color: var(--text-primary);
}
.provider-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
color: var(--text-secondary);
margin-top: 2px;
line-height: 1.4;
}
</style>

View File

@ -0,0 +1,351 @@
<template>
<el-card shadow="hover" class="ai-response-viewer">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<el-icon><ChatDotRound /></el-icon>
</div>
<span class="header-title">AI 回复</span>
</div>
<el-tag v-if="response" type="success" size="small" effect="dark">
已生成
</el-tag>
</div>
</template>
<div class="response-content">
<div v-if="!response" class="placeholder-text">
<el-icon class="placeholder-icon"><Document /></el-icon>
<p>运行实验后将在此显示 AI 回复</p>
</div>
<template v-else>
<div class="markdown-content" v-html="renderedContent"></div>
<el-divider />
<div class="stats-section">
<div class="section-label">
<el-icon><DataAnalysis /></el-icon>
<span>统计信息</span>
</div>
<div class="stats-grid">
<div v-if="response.model" class="stat-card">
<div class="stat-icon model-icon">
<el-icon><Cpu /></el-icon>
</div>
<div class="stat-info">
<span class="stat-label">模型</span>
<span class="stat-value">{{ response.model }}</span>
</div>
</div>
<div v-if="response.latency_ms" class="stat-card">
<div class="stat-icon latency-icon">
<el-icon><Timer /></el-icon>
</div>
<div class="stat-info">
<span class="stat-label">响应耗时</span>
<span class="stat-value">{{ response.latency_ms.toFixed(2) }} ms</span>
</div>
</div>
<div v-if="response.prompt_tokens" class="stat-card">
<div class="stat-icon prompt-icon">
<el-icon><EditPen /></el-icon>
</div>
<div class="stat-info">
<span class="stat-label">Prompt Tokens</span>
<span class="stat-value">{{ response.prompt_tokens }}</span>
</div>
</div>
<div v-if="response.completion_tokens" class="stat-card">
<div class="stat-icon completion-icon">
<el-icon><DocumentCopy /></el-icon>
</div>
<div class="stat-info">
<span class="stat-label">Completion Tokens</span>
<span class="stat-value">{{ response.completion_tokens }}</span>
</div>
</div>
<div v-if="response.total_tokens" class="stat-card highlight">
<div class="stat-icon total-icon">
<el-icon><Coin /></el-icon>
</div>
<div class="stat-info">
<span class="stat-label">Total Tokens</span>
<span class="stat-value">{{ response.total_tokens }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ChatDotRound, Document, DataAnalysis, Timer, EditPen, DocumentCopy, Coin, Cpu } from '@element-plus/icons-vue'
import type { AIResponse } from '@/api/rag'
const props = defineProps<{
response: AIResponse | null
}>()
const renderedContent = computed(() => {
if (!props.response?.content) return ''
return renderMarkdown(props.response.content)
})
const renderMarkdown = (text: string): string => {
let html = text
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>')
html = html.replace(/^\- (.+)$/gm, '<li>$1</li>')
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
html = html.replace(/\n\n/g, '</p><p>')
html = html.replace(/\n/g, '<br>')
return `<p>${html}</p>`
}
</script>
<style scoped>
.ai-response-viewer {
border-radius: 16px;
border: none;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.ai-response-viewer:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
}
.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: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #36d1dc 0%, #5b86e5 100%);
border-radius: 10px;
color: #ffffff;
font-size: 20px;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.placeholder-text {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #909399;
}
.placeholder-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.placeholder-text p {
margin: 0;
font-size: 14px;
}
.markdown-content {
padding: 16px;
background: #f8f9fa;
border-radius: 12px;
line-height: 1.8;
color: #303133;
max-height: 400px;
overflow-y: auto;
}
.markdown-content :deep(h1) {
font-size: 24px;
font-weight: 700;
margin: 16px 0 12px;
color: #303133;
}
.markdown-content :deep(h2) {
font-size: 20px;
font-weight: 600;
margin: 14px 0 10px;
color: #303133;
}
.markdown-content :deep(h3) {
font-size: 16px;
font-weight: 600;
margin: 12px 0 8px;
color: #303133;
}
.markdown-content :deep(pre) {
background: #1e1e1e;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin: 12px 0;
}
.markdown-content :deep(code) {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
.markdown-content :deep(pre code) {
color: #d4d4d4;
}
.markdown-content :deep(.inline-code) {
background: #e8e8e8;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
color: #e83e8c;
}
.markdown-content :deep(strong) {
font-weight: 600;
color: #303133;
}
.markdown-content :deep(em) {
font-style: italic;
color: #606266;
}
.markdown-content :deep(li) {
margin: 4px 0;
padding-left: 8px;
}
.stats-section {
margin-top: 8px;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #606266;
margin-bottom: 16px;
}
.section-label .el-icon {
color: #5b86e5;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
border-radius: 12px;
border: 1px solid #e4e7ed;
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.stat-card.highlight {
background: linear-gradient(135deg, #e8f4fd 0%, #d4e9f7 100%);
border-color: #b8d9f0;
}
.stat-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #ffffff;
font-size: 16px;
}
.model-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.latency-icon {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.prompt-icon {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.completion-icon {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.total-icon {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
color: #909399;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: #303133;
}
</style>

View File

@ -0,0 +1,120 @@
<template>
<el-select
:model-value="modelValue"
:loading="loading"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:teleported="true"
:popper-class="popperClass"
:popper-options="{ modifiers: [{ name: 'flip', enabled: true }, { name: 'preventOverflow', enabled: true }, { name: 'computeStyles', options: { adaptive: false, gpuAcceleration: false } }] }"
@update:model-value="handleChange"
>
<el-option
v-for="provider in providers"
:key="provider.name"
:label="provider.display_name"
:value="provider.name"
>
<div class="provider-option">
<div class="provider-info">
<span class="provider-name">{{ provider.display_name }}</span>
<span v-if="provider.description" class="provider-desc">{{ provider.description }}</span>
</div>
<el-tag v-if="provider.name === currentProvider" type="success" size="small" effect="plain" class="current-tag">
当前配置
</el-tag>
</div>
</el-option>
</el-select>
</template>
<script setup lang="ts">
import type { LLMProviderInfo } from '@/api/llm'
const popperClass = 'llm-selector-popper'
const props = withDefaults(
defineProps<{
modelValue?: string
providers: LLMProviderInfo[]
loading?: boolean
disabled?: boolean
clearable?: boolean
placeholder?: string
currentProvider?: string
}>(),
{
modelValue: '',
loading: false,
disabled: false,
clearable: false,
placeholder: '请选择 LLM 提供者',
currentProvider: ''
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [provider: LLMProviderInfo | undefined]
}>()
const handleChange = (value: string) => {
emit('update:modelValue', value)
const selectedProvider = props.providers.find((p) => p.name === value)
emit('change', selectedProvider)
}
</script>
<style>
.llm-selector-popper {
min-width: 300px !important;
z-index: 9999 !important;
}
.llm-selector-popper .el-select-dropdown__wrap {
max-height: 400px;
}
</style>
<style scoped>
.provider-option {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 4px 0;
gap: 12px;
}
.provider-info {
display: flex;
flex-direction: column;
line-height: 1.5;
flex: 1;
min-width: 0;
}
.provider-name {
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.provider-desc {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-tag {
flex-shrink: 0;
margin-left: 8px;
}
</style>

View File

@ -0,0 +1,299 @@
<template>
<el-card shadow="hover" class="stream-output">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper" :class="{ streaming: isStreaming }">
<el-icon><Promotion /></el-icon>
</div>
<span class="header-title">流式输出</span>
</div>
<div class="header-actions">
<el-tag v-if="isStreaming" type="warning" size="small" effect="dark" class="pulse-tag">
<el-icon class="is-loading"><Loading /></el-icon>
生成中...
</el-tag>
<el-tag v-else-if="hasContent" type="success" size="small" effect="dark">
已完成
</el-tag>
<el-tag v-else type="info" size="small" effect="plain">
等待中
</el-tag>
</div>
</div>
</template>
<div class="stream-content">
<div v-if="!hasContent && !isStreaming" class="placeholder-text">
<el-icon class="placeholder-icon"><ChatLineSquare /></el-icon>
<p>启用流式输出后AI 回复将实时显示</p>
</div>
<div v-else class="output-area">
<div class="stream-text" v-html="renderedContent"></div>
<div v-if="isStreaming" class="typing-indicator">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
<div v-if="error" class="error-section">
<el-alert
:title="error"
type="error"
:closable="false"
show-icon
/>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Promotion, Loading, ChatLineSquare } from '@element-plus/icons-vue'
const props = defineProps<{
content: string
isStreaming: boolean
error?: string | null
}>()
const hasContent = computed(() => props.content && props.content.length > 0)
const renderedContent = computed(() => {
if (!props.content) return ''
return renderMarkdown(props.content)
})
const renderMarkdown = (text: string): string => {
let html = text
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>')
html = html.replace(/^\- (.+)$/gm, '<li>$1</li>')
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
html = html.replace(/\n\n/g, '</p><p>')
html = html.replace(/\n/g, '<br>')
return `<p>${html}</p>`
}
</script>
<style scoped>
.stream-output {
border-radius: 16px;
border: none;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.stream-output:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
}
.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: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
border-radius: 10px;
color: #ffffff;
font-size: 20px;
transition: all 0.3s ease;
}
.icon-wrapper.streaming {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(17, 153, 142, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(17, 153, 142, 0);
}
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.pulse-tag {
animation: pulse-tag 1.5s ease-in-out infinite;
}
@keyframes pulse-tag {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.placeholder-text {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #909399;
}
.placeholder-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.placeholder-text p {
margin: 0;
font-size: 14px;
}
.output-area {
padding: 16px;
background: #f8f9fa;
border-radius: 12px;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
}
.stream-text {
line-height: 1.8;
color: #303133;
}
.stream-text :deep(h1) {
font-size: 24px;
font-weight: 700;
margin: 16px 0 12px;
color: #303133;
}
.stream-text :deep(h2) {
font-size: 20px;
font-weight: 600;
margin: 14px 0 10px;
color: #303133;
}
.stream-text :deep(h3) {
font-size: 16px;
font-weight: 600;
margin: 12px 0 8px;
color: #303133;
}
.stream-text :deep(pre) {
background: #1e1e1e;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin: 12px 0;
}
.stream-text :deep(code) {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
.stream-text :deep(pre code) {
color: #d4d4d4;
}
.stream-text :deep(.inline-code) {
background: #e8e8e8;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
color: #e83e8c;
}
.stream-text :deep(strong) {
font-weight: 600;
color: #303133;
}
.stream-text :deep(em) {
font-style: italic;
color: #606266;
}
.stream-text :deep(li) {
margin: 4px 0;
padding-left: 8px;
}
.typing-indicator {
display: flex;
gap: 4px;
margin-top: 12px;
padding: 8px 12px;
background: rgba(17, 153, 142, 0.1);
border-radius: 8px;
width: fit-content;
}
.typing-indicator .dot {
width: 8px;
height: 8px;
background: #11998e;
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out both;
}
.typing-indicator .dot:nth-child(1) {
animation-delay: -0.32s;
}
.typing-indicator .dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.error-section {
margin-top: 16px;
}
</style>

View File

@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/main.css'
import App from './App.vue'
import router from './router'

View File

@ -34,6 +34,12 @@ const routes: Array<RouteRecordRaw> = [
name: 'EmbeddingConfig',
component: () => import('@/views/admin/embedding/index.vue'),
meta: { title: '嵌入模型配置' }
},
{
path: '/admin/llm',
name: 'LLMConfig',
component: () => import('@/views/admin/llm/index.vue'),
meta: { title: 'LLM 模型配置' }
}
]

View File

@ -0,0 +1,161 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
getLLMProviders,
getLLMConfig,
updateLLMConfig,
testLLM,
type LLMProviderInfo,
type LLMConfig,
type LLMConfigUpdate,
type LLMTestResult
} from '@/api/llm'
export const useLLMStore = defineStore('llm', () => {
const providers = ref<LLMProviderInfo[]>([])
const currentConfig = ref<LLMConfig>({
provider: '',
config: {}
})
const loading = ref(false)
const providersLoading = ref(false)
const testResult = ref<LLMTestResult | null>(null)
const testLoading = ref(false)
const currentProvider = computed(() => {
return providers.value.find(p => p.name === currentConfig.value.provider)
})
const configSchema = computed(() => {
return currentProvider.value?.config_schema || { properties: {} }
})
const loadProviders = async () => {
providersLoading.value = true
try {
const res: any = await getLLMProviders()
providers.value = res?.providers || res?.data?.providers || []
} catch (error) {
console.error('Failed to load LLM providers:', error)
throw error
} finally {
providersLoading.value = false
}
}
const loadConfig = async () => {
loading.value = true
try {
const res: any = await getLLMConfig()
const config = res?.data || res
if (config) {
currentConfig.value = {
provider: config.provider || '',
config: config.config || {},
updated_at: config.updated_at
}
}
} catch (error) {
console.error('Failed to load LLM config:', error)
throw error
} finally {
loading.value = false
}
}
const saveCurrentConfig = async () => {
loading.value = true
try {
const updateData: LLMConfigUpdate = {
provider: currentConfig.value.provider,
config: currentConfig.value.config
}
await updateLLMConfig(updateData)
} catch (error) {
console.error('Failed to save LLM config:', error)
throw error
} finally {
loading.value = false
}
}
const runTest = async (testPrompt?: string): Promise<LLMTestResult> => {
testLoading.value = true
testResult.value = null
try {
const result = await testLLM({
test_prompt: testPrompt,
provider: currentConfig.value.provider,
config: currentConfig.value.config
})
testResult.value = result
return result
} catch (error: any) {
const errorResult: LLMTestResult = {
success: false,
error: error?.message || '连接测试失败'
}
testResult.value = errorResult
return errorResult
} finally {
testLoading.value = false
}
}
const setProvider = (providerName: string) => {
currentConfig.value.provider = providerName
const provider = providers.value.find(p => p.name === providerName)
if (provider?.config_schema?.properties) {
const newConfig: Record<string, any> = {}
Object.entries(provider.config_schema.properties).forEach(([key, field]: [string, any]) => {
if (field.default !== undefined) {
newConfig[key] = field.default
} else {
switch (field.type) {
case 'string':
newConfig[key] = ''
break
case 'integer':
case 'number':
newConfig[key] = field.minimum ?? 0
break
case 'boolean':
newConfig[key] = false
break
default:
newConfig[key] = null
}
}
})
currentConfig.value.config = newConfig
} else {
currentConfig.value.config = {}
}
}
const updateConfigValue = (key: string, value: any) => {
currentConfig.value.config[key] = value
}
const clearTestResult = () => {
testResult.value = null
}
return {
providers,
currentConfig,
loading,
providersLoading,
testResult,
testLoading,
currentProvider,
configSchema,
loadProviders,
loadConfig,
saveCurrentConfig,
runTest,
setProvider,
updateConfigValue,
clearTestResult
}
})

View File

@ -0,0 +1,126 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
runRagExperiment,
createSSEConnection,
type AIResponse,
type RetrievalResult,
type RagExperimentRequest,
type RagExperimentResult
} from '@/api/rag'
export const useRagStore = defineStore('rag', () => {
const retrievalResults = ref<RetrievalResult[]>([])
const finalPrompt = ref('')
const aiResponse = ref<AIResponse | null>(null)
const totalLatencyMs = ref<number>(0)
const loading = ref(false)
const streaming = ref(false)
const streamContent = ref('')
const streamError = ref<string | null>(null)
const hasResults = computed(() => retrievalResults.value.length > 0 || aiResponse.value !== null)
const abortStream = ref<(() => void) | null>(null)
const runExperiment = async (params: RagExperimentRequest) => {
loading.value = true
streamError.value = null
try {
const result: RagExperimentResult = await runRagExperiment(params)
retrievalResults.value = result.retrieval_results || []
finalPrompt.value = result.final_prompt || ''
aiResponse.value = result.ai_response || null
totalLatencyMs.value = result.total_latency_ms || 0
return result
} catch (error: any) {
streamError.value = error?.message || '实验运行失败'
throw error
} finally {
loading.value = false
}
}
const startStream = (params: RagExperimentRequest) => {
streaming.value = true
streamContent.value = ''
streamError.value = null
aiResponse.value = null
abortStream.value = createSSEConnection(
'/admin/rag/experiments/stream',
params,
(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
} else if (parsed.type === 'error') {
streamError.value = parsed.message || '流式输出错误'
}
} catch {
streamContent.value += data
}
},
(error: Error) => {
streaming.value = false
streamError.value = error.message
},
() => {
streaming.value = false
}
)
}
const stopStream = () => {
if (abortStream.value) {
abortStream.value()
abortStream.value = null
}
streaming.value = false
}
const clearResults = () => {
retrievalResults.value = []
finalPrompt.value = ''
aiResponse.value = null
totalLatencyMs.value = 0
streamContent.value = ''
streamError.value = null
}
return {
retrievalResults,
finalPrompt,
aiResponse,
totalLatencyMs,
loading,
streaming,
streamContent,
streamError,
hasResults,
runExperiment,
startStream,
stopStream,
clearResults
}
})

View File

@ -0,0 +1,486 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap');
:root {
--primary-color: #4F7CFF;
--primary-light: #6B91FF;
--primary-lighter: #E8EEFF;
--primary-dark: #3A5FD9;
--secondary-color: #6366F1;
--secondary-light: #818CF8;
--accent-color: #10B981;
--accent-light: #34D399;
--warning-color: #F59E0B;
--danger-color: #EF4444;
--success-color: #10B981;
--info-color: #3B82F6;
--bg-primary: #F8FAFC;
--bg-secondary: #FFFFFF;
--bg-tertiary: #F1F5F9;
--bg-hover: #F1F5F9;
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-tertiary: #94A3B8;
--text-placeholder: #CBD5E1;
--border-color: #E2E8F0;
--border-light: #F1F5F9;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--transition-slow: 0.35s ease;
--font-sans: 'DM Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
background-color: var(--bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.el-menu {
font-family: var(--font-sans) !important;
border-bottom: 1px solid var(--border-color) !important;
background-color: var(--bg-secondary) !important;
}
.el-menu-item {
font-weight: 500 !important;
transition: all var(--transition-fast) !important;
}
.el-menu-item:hover {
background-color: var(--bg-hover) !important;
}
.el-menu-item.is-active {
color: var(--primary-color) !important;
border-bottom-color: var(--primary-color) !important;
background-color: var(--primary-lighter) !important;
}
.el-card {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--shadow-sm) !important;
transition: all var(--transition-normal) !important;
background-color: var(--bg-secondary) !important;
}
.el-card:hover {
box-shadow: var(--shadow-md) !important;
}
.el-card__header {
border-bottom: 1px solid var(--border-light) !important;
padding: 16px 20px !important;
font-weight: 600 !important;
color: var(--text-primary) !important;
}
.el-card__body {
padding: 20px !important;
}
.el-button--primary {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
font-weight: 500 !important;
transition: all var(--transition-fast) !important;
}
.el-button--primary:hover {
background-color: var(--primary-light) !important;
border-color: var(--primary-light) !important;
transform: translateY(-1px);
box-shadow: var(--shadow-md) !important;
}
.el-button--default {
font-weight: 500 !important;
border-color: var(--border-color) !important;
transition: all var(--transition-fast) !important;
}
.el-button--default:hover {
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
background-color: var(--primary-lighter) !important;
}
.el-input__wrapper {
border-radius: var(--radius-md) !important;
box-shadow: none !important;
border: 1px solid var(--border-color) !important;
transition: all var(--transition-fast) !important;
}
.el-input__wrapper:hover {
border-color: var(--primary-light) !important;
}
.el-input__wrapper.is-focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px var(--primary-lighter) !important;
}
.el-select {
--el-select-border-color-hover: var(--primary-light) !important;
}
.el-select .el-input__wrapper {
border-radius: var(--radius-md) !important;
}
.el-select-dropdown {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--shadow-xl) !important;
margin-top: 8px !important;
}
.el-select-dropdown__wrap {
max-height: 320px !important;
}
.el-select-dropdown__item {
padding: 10px 16px !important;
line-height: 1.5 !important;
transition: all var(--transition-fast) !important;
}
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover {
background-color: var(--primary-lighter) !important;
color: var(--primary-color) !important;
}
.el-select-dropdown__item.is-selected {
background-color: var(--primary-lighter) !important;
color: var(--primary-color) !important;
font-weight: 600 !important;
}
.el-tag {
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
border: none !important;
}
.el-tag--success {
background-color: #D1FAE5 !important;
color: #059669 !important;
}
.el-tag--warning {
background-color: #FEF3C7 !important;
color: #D97706 !important;
}
.el-tag--danger {
background-color: #FEE2E2 !important;
color: #DC2626 !important;
}
.el-tag--info {
background-color: #E0E7FF !important;
color: #4F46E5 !important;
}
.el-table {
border-radius: var(--radius-lg) !important;
overflow: hidden !important;
}
.el-table th.el-table__cell {
background-color: var(--bg-tertiary) !important;
font-weight: 600 !important;
color: var(--text-secondary) !important;
}
.el-table td.el-table__cell {
border-bottom: 1px solid var(--border-light) !important;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background-color: var(--bg-tertiary) !important;
}
.el-table__row:hover > td.el-table__cell {
background-color: var(--primary-lighter) !important;
}
.el-tabs__item {
font-weight: 500 !important;
transition: all var(--transition-fast) !important;
}
.el-tabs__item.is-active {
color: var(--primary-color) !important;
}
.el-tabs__active-bar {
background-color: var(--primary-color) !important;
}
.el-dialog {
border-radius: var(--radius-xl) !important;
overflow: hidden !important;
}
.el-dialog__header {
padding: 20px 24px !important;
border-bottom: 1px solid var(--border-light) !important;
}
.el-dialog__title {
font-weight: 600 !important;
font-size: 18px !important;
}
.el-dialog__body {
padding: 24px !important;
}
.el-form-item__label {
font-weight: 500 !important;
color: var(--text-secondary) !important;
}
.el-slider__bar {
background-color: var(--primary-color) !important;
}
.el-slider__button {
border-color: var(--primary-color) !important;
}
.el-switch.is-checked .el-switch__core {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.el-alert {
border-radius: var(--radius-md) !important;
border: none !important;
}
.el-alert--info {
background-color: #EFF6FF !important;
}
.el-alert--success {
background-color: #ECFDF5 !important;
}
.el-alert--warning {
background-color: #FFFBEB !important;
}
.el-alert--error {
background-color: #FEF2F2 !important;
}
.el-divider {
border-color: var(--border-light) !important;
}
.el-empty__description {
color: var(--text-tertiary) !important;
}
.el-loading-mask {
border-radius: var(--radius-lg) !important;
}
.el-descriptions {
border-radius: var(--radius-md) !important;
overflow: hidden !important;
}
.el-descriptions__label {
background-color: var(--bg-tertiary) !important;
font-weight: 500 !important;
}
.fade-in-up {
animation: fadeInUp 0.5s ease-out forwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-in-left {
animation: slideInLeft 0.4s ease-out forwards;
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.scale-in {
animation: scaleIn 0.3s ease-out forwards;
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.page-container {
padding: 24px;
min-height: calc(100vh - 60px);
background-color: var(--bg-primary);
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px 0;
letter-spacing: -0.5px;
}
.page-desc {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.6;
}
.card-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: 18px;
}
.card-icon.primary {
background-color: var(--primary-lighter);
color: var(--primary-color);
}
.card-icon.success {
background-color: #D1FAE5;
color: #059669;
}
.card-icon.warning {
background-color: #FEF3C7;
color: #D97706;
}
.card-icon.info {
background-color: #E0E7FF;
color: #4F46E5;
}
.stat-card {
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
opacity: 0;
transition: opacity var(--transition-fast);
}
.stat-card:hover::before {
opacity: 1;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
transition: background var(--transition-fast);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
::selection {
background-color: var(--primary-lighter);
color: var(--primary-dark);
}
@media (max-width: 768px) {
.page-container {
padding: 16px;
}
.page-title {
font-size: 20px;
}
}

View File

@ -29,7 +29,8 @@ export interface LLMTestResult {
export interface LLMTestRequest {
test_prompt?: string
config?: LLMConfigUpdate
provider?: string
config?: Record<string, any>
}
export interface LLMProvidersResponse {

View File

@ -5,7 +5,7 @@ import { useTenantStore } from '@/stores/tenant'
// 创建 axios 实例
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API || '/api',
timeout: 10000
timeout: 60000
})
// 请求拦截器

View File

@ -6,95 +6,91 @@
<h1 class="page-title">嵌入模型配置</h1>
<p class="page-desc">配置和管理系统使用的嵌入模型支持多种提供者切换配置修改后需保存才能生效</p>
</div>
<div class="header-actions">
<el-tag v-if="currentConfig.updated_at" type="info" size="large" effect="plain">
<el-icon class="tag-icon"><Clock /></el-icon>
上次更新: {{ formatDate(currentConfig.updated_at) }}
</el-tag>
<div class="header-actions" v-if="currentConfig.updated_at">
<div class="update-info">
<el-icon class="update-icon"><Clock /></el-icon>
<span>上次更新: {{ formatDate(currentConfig.updated_at) }}</span>
</div>
</div>
</div>
</div>
<el-row :gutter="24" v-loading="pageLoading" element-loading-text="加载中...">
<el-col :xs="24" :sm="24" :md="12" :lg="12">
<div class="config-card-wrapper">
<el-card shadow="hover" class="config-card">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<el-icon><Setting /></el-icon>
</div>
<span class="header-title">模型配置</span>
<el-card shadow="hover" class="config-card">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<el-icon><Setting /></el-icon>
</div>
<span class="header-title">模型配置</span>
</div>
</template>
</div>
</template>
<div class="card-content">
<div class="provider-select-section">
<div class="section-label">
<el-icon><Connection /></el-icon>
<span>选择提供者</span>
</div>
<EmbeddingProviderSelect
v-model="currentConfig.provider"
:providers="providers"
:loading="providersLoading"
placeholder="请选择嵌入模型提供者"
@change="handleProviderChange"
/>
<transition name="fade">
<div v-if="currentProvider" class="provider-info">
<el-icon class="info-icon"><InfoFilled /></el-icon>
<span class="info-text">{{ currentProvider.description }}</span>
</div>
</transition>
<div class="card-content">
<div class="provider-select-section">
<div class="section-label">
<el-icon><Connection /></el-icon>
<span>选择提供者</span>
</div>
<el-divider />
<transition name="slide-fade" mode="out-in">
<div v-if="currentConfig.provider" key="form" class="config-form-section">
<EmbeddingConfigForm
ref="configFormRef"
:schema="configSchema"
v-model="currentConfig.config"
label-width="140px"
/>
<EmbeddingProviderSelect
v-model="currentConfig.provider"
:providers="providers"
:loading="providersLoading"
placeholder="请选择嵌入模型提供者"
@change="handleProviderChange"
/>
<transition name="fade">
<div v-if="currentProvider" class="provider-info">
<el-icon class="info-icon"><InfoFilled /></el-icon>
<span class="info-text">{{ currentProvider.description }}</span>
</div>
<el-empty v-else key="empty" description="请先选择一个嵌入模型提供者" :image-size="120">
<template #image>
<div class="empty-icon">
<el-icon><Box /></el-icon>
</div>
</template>
</el-empty>
</transition>
</div>
<template #footer>
<div class="card-footer">
<el-button size="large" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存配置
</el-button>
<el-divider />
<transition name="slide-fade" mode="out-in">
<div v-if="currentConfig.provider" key="form" class="config-form-section">
<EmbeddingConfigForm
ref="configFormRef"
:schema="configSchema"
v-model="currentConfig.config"
label-width="140px"
/>
</div>
</template>
</el-card>
</div>
<el-empty v-else key="empty" description="请先选择一个嵌入模型提供者" :image-size="120">
<template #image>
<div class="empty-icon">
<el-icon><Box /></el-icon>
</div>
</template>
</el-empty>
</transition>
</div>
<template #footer>
<div class="card-footer">
<el-button size="large" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存配置
</el-button>
</div>
</template>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="12" :lg="12">
<div class="right-column">
<div class="test-panel-wrapper">
<EmbeddingTestPanel
:config="{ provider: currentConfig.provider, config: currentConfig.config }"
/>
</div>
<EmbeddingTestPanel
:config="{ provider: currentConfig.provider, config: currentConfig.config }"
/>
<el-card shadow="hover" class="formats-card">
<template #header>
@ -192,7 +188,6 @@ const handleReset = async () => {
await embeddingStore.loadConfig()
ElMessage.success('配置已重置')
} catch (error) {
//
}
}
@ -219,19 +214,18 @@ onMounted(() => {
<style scoped>
.embedding-config-page {
padding: 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: calc(100vh - 60px);
}
.page-header {
margin-bottom: 32px;
animation: slideDown 0.6s ease-out;
margin-bottom: 24px;
animation: slideDown 0.4s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
transform: translateY(-16px);
}
to {
opacity: 1;
@ -253,18 +247,17 @@ onMounted(() => {
}
.page-title {
margin: 0 0 12px 0;
font-size: 28px;
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 700;
color: #ffffff;
color: var(--text-primary);
letter-spacing: -0.5px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-desc {
margin: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
color: var(--text-secondary);
line-height: 1.6;
}
@ -273,20 +266,30 @@ onMounted(() => {
align-items: center;
}
.tag-icon {
margin-right: 4px;
.update-info {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background-color: var(--bg-tertiary);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.config-card-wrapper,
.test-panel-wrapper,
.formats-card {
animation: fadeInUp 0.6s ease-out;
.update-icon {
font-size: 14px;
color: var(--text-tertiary);
}
.config-card {
animation: fadeInUp 0.5s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
transform: translateY(20px);
}
to {
opacity: 1;
@ -294,20 +297,6 @@ onMounted(() => {
}
}
.config-card {
border-radius: 16px;
border: none;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.config-card:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
transform: translateY(-4px);
}
.card-header {
display: flex;
justify-content: space-between;
@ -322,21 +311,21 @@ onMounted(() => {
}
.icon-wrapper {
width: 40px;
height: 40px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: var(--primary-lighter);
border-radius: 10px;
color: #ffffff;
font-size: 20px;
color: var(--primary-color);
font-size: 18px;
}
.header-title {
font-size: 16px;
font-size: 15px;
font-weight: 600;
color: #303133;
color: var(--text-primary);
}
.card-content {
@ -352,32 +341,32 @@ onMounted(() => {
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
font-size: 13px;
font-weight: 600;
color: #606266;
color: var(--text-secondary);
}
.section-label .el-icon {
color: #667eea;
color: var(--primary-color);
}
.provider-info {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
gap: 10px;
margin-top: 14px;
padding: 14px 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
background-color: var(--bg-tertiary);
border-radius: 10px;
font-size: 13px;
color: #606266;
color: var(--text-secondary);
line-height: 1.6;
border-left: 3px solid #667eea;
border-left: 3px solid var(--primary-color);
}
.info-icon {
margin-top: 2px;
color: #667eea;
color: var(--primary-color);
font-size: 16px;
}
@ -396,17 +385,17 @@ onMounted(() => {
}
.config-form-section::-webkit-scrollbar-track {
background: #f1f1f1;
background: var(--bg-tertiary);
border-radius: 3px;
}
.config-form-section::-webkit-scrollbar-thumb {
background: #c0c4cc;
background: var(--text-tertiary);
border-radius: 3px;
}
.config-form-section::-webkit-scrollbar-thumb:hover {
background: #a0a4ac;
background: var(--text-secondary);
}
.card-footer {
@ -423,38 +412,28 @@ onMounted(() => {
}
.formats-card {
border-radius: 16px;
border: none;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.formats-card:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
transform: translateY(-4px);
animation: fadeInUp 0.6s ease-out;
}
.empty-icon {
width: 120px;
height: 120px;
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
background-color: var(--bg-tertiary);
border-radius: 50%;
margin: 0 auto;
}
.empty-icon .el-icon {
font-size: 60px;
color: #c0c4cc;
font-size: 48px;
color: var(--text-tertiary);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
transition: opacity 0.25s ease;
}
.fade-enter-from,
@ -463,21 +442,21 @@ onMounted(() => {
}
.slide-fade-enter-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(-20px);
transform: translateX(-16px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(20px);
transform: translateX(16px);
}
@media (max-width: 768px) {
@ -486,7 +465,7 @@ onMounted(() => {
}
.page-title {
font-size: 24px;
font-size: 20px;
}
.header-content {

View File

@ -0,0 +1,470 @@
<template>
<div class="llm-config-page">
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">LLM 模型配置</h1>
<p class="page-desc">配置和管理系统使用的大语言模型支持多种提供者切换配置修改后需保存才能生效</p>
</div>
<div class="header-actions" v-if="currentConfig.updated_at">
<div class="update-info">
<el-icon class="update-icon"><Clock /></el-icon>
<span>上次更新: {{ formatDate(currentConfig.updated_at) }}</span>
</div>
</div>
</div>
</div>
<el-row :gutter="24" v-loading="pageLoading" element-loading-text="加载中...">
<el-col :xs="24" :sm="24" :md="12" :lg="12">
<el-card shadow="hover" class="config-card">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper llm-icon">
<el-icon><Cpu /></el-icon>
</div>
<span class="header-title">模型配置</span>
</div>
</div>
</template>
<div class="card-content">
<div class="provider-select-section">
<div class="section-label">
<el-icon><Connection /></el-icon>
<span>选择提供者</span>
</div>
<ProviderSelect
v-model="currentConfig.provider"
:providers="providers"
:loading="providersLoading"
placeholder="请选择 LLM 提供者"
@change="handleProviderChange"
/>
<transition name="fade">
<div v-if="currentProvider" class="provider-info">
<el-icon class="info-icon"><InfoFilled /></el-icon>
<span class="info-text">{{ currentProvider.description }}</span>
</div>
</transition>
</div>
<el-divider />
<transition name="slide-fade" mode="out-in">
<div v-if="currentConfig.provider" key="form" class="config-form-section">
<ConfigForm
ref="configFormRef"
:schema="configSchema"
v-model="currentConfig.config"
label-width="140px"
/>
</div>
<el-empty v-else key="empty" description="请先选择一个 LLM 提供者" :image-size="120">
<template #image>
<div class="empty-icon">
<el-icon><Box /></el-icon>
</div>
</template>
</el-empty>
</transition>
</div>
<template #footer>
<div class="card-footer">
<el-button size="large" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存配置
</el-button>
</div>
</template>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="12" :lg="12">
<TestPanel
:test-fn="handleTest"
:can-test="!!currentConfig.provider"
title="LLM 连接测试"
input-label="测试提示词"
input-placeholder="请输入测试提示词(可选,默认使用系统预设提示词)"
:show-token-stats="true"
/>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Cpu, Connection, InfoFilled, Box, RefreshLeft, Check, Clock } from '@element-plus/icons-vue'
import { useLLMStore } from '@/stores/llm'
import ProviderSelect from '@/components/common/ProviderSelect.vue'
import ConfigForm from '@/components/common/ConfigForm.vue'
import TestPanel from '@/components/common/TestPanel.vue'
import type { TestResult } from '@/components/common/TestPanel.vue'
const llmStore = useLLMStore()
const configFormRef = ref<InstanceType<typeof ConfigForm>>()
const saving = ref(false)
const pageLoading = ref(false)
const providers = computed(() => llmStore.providers)
const currentConfig = computed(() => llmStore.currentConfig)
const currentProvider = computed(() => llmStore.currentProvider)
const configSchema = computed(() => llmStore.configSchema)
const providersLoading = computed(() => llmStore.providersLoading)
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const handleProviderChange = (provider: any) => {
if (provider?.name) {
llmStore.setProvider(provider.name)
}
}
const handleSave = async () => {
if (!currentConfig.value.provider) {
ElMessage.warning('请先选择 LLM 提供者')
return
}
try {
const valid = await configFormRef.value?.validate()
if (!valid) {
return
}
} catch (error) {
ElMessage.warning('请检查配置表单中的必填项')
return
}
saving.value = true
try {
await llmStore.saveCurrentConfig()
ElMessage.success('配置保存成功')
} catch (error) {
ElMessage.error('配置保存失败')
} finally {
saving.value = false
}
}
const handleReset = async () => {
try {
await ElMessageBox.confirm('确定要重置配置吗?将恢复为当前保存的配置。', '确认重置', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await llmStore.loadConfig()
ElMessage.success('配置已重置')
} catch (error) {
}
}
const handleTest = async (input?: string): Promise<TestResult> => {
return await llmStore.runTest(input)
}
const initPage = async () => {
pageLoading.value = true
try {
await Promise.all([
llmStore.loadProviders(),
llmStore.loadConfig()
])
} catch (error) {
ElMessage.error('初始化页面失败')
} finally {
pageLoading.value = false
}
}
onMounted(() => {
initPage()
})
</script>
<style scoped>
.llm-config-page {
padding: 24px;
min-height: calc(100vh - 60px);
}
.page-header {
margin-bottom: 24px;
animation: slideDown 0.4s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.title-section {
flex: 1;
min-width: 300px;
}
.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;
}
.header-actions {
display: flex;
align-items: center;
}
.update-info {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background-color: var(--bg-tertiary);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.update-icon {
font-size: 14px;
color: var(--text-tertiary);
}
.config-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.llm-icon {
background: linear-gradient(135deg, #E0F2FE 0%, #BAE6FD 100%);
color: #0284C7;
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.card-content {
padding: 8px 0;
}
.provider-select-section {
margin-bottom: 16px;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.section-label .el-icon {
color: var(--primary-color);
}
.provider-info {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 14px;
padding: 14px 16px;
background-color: var(--bg-tertiary);
border-radius: 10px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
border-left: 3px solid var(--primary-color);
}
.info-icon {
margin-top: 2px;
color: var(--primary-color);
font-size: 16px;
}
.info-text {
flex: 1;
}
.config-form-section {
max-height: 400px;
overflow-y: auto;
padding-right: 8px;
}
.config-form-section::-webkit-scrollbar {
width: 6px;
}
.config-form-section::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
.config-form-section::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
}
.config-form-section::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.card-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 8px;
}
.empty-icon {
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-tertiary);
border-radius: 50%;
margin: 0 auto;
}
.empty-icon .el-icon {
font-size: 48px;
color: var(--text-tertiary);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-fade-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-fade-leave-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(-16px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(16px);
}
@media (max-width: 768px) {
.llm-config-page {
padding: 16px;
}
.page-title {
font-size: 20px;
}
.header-content {
flex-direction: column;
}
.title-section {
min-width: 100%;
}
.config-form-section {
max-height: 300px;
}
}
</style>

View File

@ -1,28 +1,245 @@
<template>
<div class="dashboard-container">
<div class="dashboard-page">
<div class="page-header">
<h1 class="page-title">控制台</h1>
<p class="page-desc">系统概览与数据统计</p>
</div>
<el-row :gutter="20" v-loading="loading">
<el-col :span="6">
<el-card shadow="hover">
<template #header>知识库总数</template>
<div class="card-content">{{ stats.knowledgeBases }}</div>
<el-col :xs="12" :sm="12" :md="6" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon primary">
<el-icon><FolderOpened /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ stats.knowledgeBases }}</span>
<span class="stat-label">知识库总数</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>文档总数</template>
<div class="card-content">{{ stats.totalDocuments }}</div>
<el-col :xs="12" :sm="12" :md="6" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon success">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ stats.totalDocuments }}</span>
<span class="stat-label">文档总数</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>总消息数</template>
<div class="card-content">{{ stats.totalMessages.toLocaleString() }}</div>
<el-col :xs="12" :sm="12" :md="6" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon warning">
<el-icon><ChatDotSquare /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ stats.totalMessages.toLocaleString() }}</span>
<span class="stat-label">总消息数</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-col :xs="12" :sm="12" :md="6" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon info">
<el-icon><Monitor /></el-icon>
</div>
<div class="stat-info">
<span class="stat-value">{{ stats.totalSessions }}</span>
<span class="stat-label">会话总数</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :xs="24" :sm="24" :md="8" :lg="8">
<el-card shadow="hover" class="metric-card">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper primary">
<el-icon><Cpu /></el-icon>
</div>
<span class="header-title">Token 消耗统计</span>
</div>
<el-tag type="primary" size="small" effect="plain">实时</el-tag>
</div>
</template>
<div class="metric-content">
<div class="metric-main">
<span class="metric-value primary">{{ formatNumber(stats.totalTokens) }}</span>
<span class="metric-label">总消耗</span>
</div>
<div class="metric-divider"></div>
<div class="metric-detail">
<div class="detail-item">
<span class="detail-label">输入</span>
<span class="detail-value">{{ formatNumber(stats.promptTokens) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">输出</span>
<span class="detail-value">{{ formatNumber(stats.completionTokens) }}</span>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="8" :lg="8">
<el-card shadow="hover" class="metric-card">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper success">
<el-icon><Timer /></el-icon>
</div>
<span class="header-title">响应时间统计</span>
</div>
<el-tag :type="stats.slowRequestsCount > 0 ? 'warning' : 'success'" size="small" effect="plain">
{{ stats.slowRequestsCount }} 次超时
</el-tag>
</div>
</template>
<div class="metric-content">
<div class="latency-grid">
<div class="latency-item">
<span class="metric-value success">{{ formatLatency(stats.avgLatencyMs) }}</span>
<span class="metric-label">平均耗时</span>
</div>
<div class="latency-item">
<span class="metric-value">{{ formatLatency(stats.lastLatencyMs) }}</span>
<span class="metric-label">上次耗时</span>
</div>
</div>
<div class="metric-divider"></div>
<div class="latency-stats">
<div class="stat-row">
<div class="stat-col">
<span class="stat-label">P95</span>
<span class="stat-value">{{ formatLatency(stats.p95LatencyMs) }}</span>
</div>
<div class="stat-col">
<span class="stat-label">P99</span>
<span class="stat-value">{{ formatLatency(stats.p99LatencyMs) }}</span>
</div>
</div>
<div class="stat-row">
<div class="stat-col">
<span class="stat-label">最小</span>
<span class="stat-value">{{ formatLatency(stats.minLatencyMs) }}</span>
</div>
<div class="stat-col">
<span class="stat-label">最大</span>
<span class="stat-value">{{ formatLatency(stats.maxLatencyMs) }}</span>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="8" :lg="8">
<el-card shadow="hover" class="metric-card">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper warning">
<el-icon><DataLine /></el-icon>
</div>
<span class="header-title">请求统计</span>
</div>
<el-tag type="info" size="small" effect="plain">阈值 {{ stats.latencyThresholdMs }}ms</el-tag>
</div>
</template>
<div class="metric-content">
<div class="requests-grid">
<div class="request-item">
<span class="metric-value primary">{{ stats.aiRequestsCount }}</span>
<span class="metric-label">AI 请求</span>
</div>
<div class="request-item">
<span class="metric-value" :class="{ danger: stats.errorRequestsCount > 0 }">{{ stats.errorRequestsCount }}</span>
<span class="metric-label">错误</span>
</div>
<div class="request-item">
<span class="metric-value" :class="{ warning: stats.slowRequestsCount > 0 }">{{ stats.slowRequestsCount }}</span>
<span class="metric-label">超时</span>
</div>
</div>
<div class="metric-divider"></div>
<div class="rate-stats">
<div class="rate-item">
<span class="rate-label">错误率</span>
<span class="rate-value" :class="{ danger: parseFloat(errorRate) > 5 }">{{ errorRate }}%</span>
</div>
<div class="rate-item">
<span class="rate-label">超时率</span>
<span class="rate-value" :class="{ warning: parseFloat(timeoutRate) > 10 }">{{ timeoutRate }}%</span>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="24">
<el-card shadow="hover">
<template #header>会话总数</template>
<div class="card-content">{{ stats.totalSessions }}</div>
<template #header>
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper info">
<el-icon><InfoFilled /></el-icon>
</div>
<span class="header-title">使用说明</span>
</div>
</div>
</template>
<div class="help-content">
<div class="help-item">
<div class="help-icon primary">
<el-icon><FolderOpened /></el-icon>
</div>
<div class="help-text">
<h4>知识库管理</h4>
<p>上传文档并建立向量索引支持 PDFWordTXT 等格式</p>
</div>
</div>
<div class="help-item">
<div class="help-icon success">
<el-icon><Cpu /></el-icon>
</div>
<div class="help-text">
<h4>RAG 实验室</h4>
<p>测试检索增强生成效果查看检索结果和 AI 响应</p>
</div>
</div>
<div class="help-item">
<div class="help-icon warning">
<el-icon><Connection /></el-icon>
</div>
<div class="help-text">
<h4>嵌入模型配置</h4>
<p>配置文本嵌入模型用于文档向量化</p>
</div>
</div>
<div class="help-item">
<div class="help-icon info">
<el-icon><ChatDotSquare /></el-icon>
</div>
<div class="help-text">
<h4>LLM 模型配置</h4>
<p>配置大语言模型支持 OpenAIDeepSeekOllama </p>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
@ -30,7 +247,8 @@
</template>
<script setup lang="ts">
import { ref, reactive, 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 { getDashboardStats } from '@/api/dashboard'
const loading = ref(false)
@ -38,9 +256,49 @@ const stats = reactive({
knowledgeBases: 0,
totalDocuments: 0,
totalMessages: 0,
totalSessions: 0
totalSessions: 0,
totalTokens: 0,
promptTokens: 0,
completionTokens: 0,
aiRequestsCount: 0,
avgLatencyMs: 0,
lastLatencyMs: 0,
slowRequestsCount: 0,
errorRequestsCount: 0,
p95LatencyMs: 0,
p99LatencyMs: 0,
minLatencyMs: 0,
maxLatencyMs: 0,
latencyThresholdMs: 5000
})
const errorRate = computed(() => {
if (stats.aiRequestsCount === 0) return '0.00'
return ((stats.errorRequestsCount / stats.aiRequestsCount) * 100).toFixed(2)
})
const timeoutRate = computed(() => {
if (stats.aiRequestsCount === 0) return '0.00'
return ((stats.slowRequestsCount / stats.aiRequestsCount) * 100).toFixed(2)
})
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatLatency = (ms: number | null | undefined) => {
if (ms === null || ms === undefined) return '-'
if (ms >= 1000) {
return (ms / 1000).toFixed(2) + 's'
}
return ms.toFixed(0) + 'ms'
}
const fetchStats = async () => {
loading.value = true
try {
@ -49,6 +307,19 @@ const fetchStats = async () => {
stats.totalDocuments = res.totalDocuments || 0
stats.totalMessages = res.totalMessages || 0
stats.totalSessions = res.totalSessions || 0
stats.totalTokens = res.totalTokens || 0
stats.promptTokens = res.promptTokens || 0
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) {
console.error('Failed to fetch dashboard stats:', error)
} finally {
@ -62,12 +333,388 @@ onMounted(() => {
</script>
<style scoped>
.dashboard-container {
padding: 20px;
.dashboard-page {
padding: 24px;
min-height: calc(100vh - 60px);
}
.card-content {
.page-header {
margin-bottom: 24px;
}
.page-title {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: bold;
text-align: center;
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;
}
.stat-card {
animation: fadeInUp 0.5s ease-out;
}
.stat-card:nth-child(1) { animation-delay: 0s; }
.stat-card:nth-child(2) { animation-delay: 0.1s; }
.stat-card:nth-child(3) { animation-delay: 0.2s; }
.stat-card:nth-child(4) { animation-delay: 0.3s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 22px;
}
.stat-icon.primary {
background-color: var(--primary-lighter);
color: var(--primary-color);
}
.stat-icon.success {
background-color: #D1FAE5;
color: #059669;
}
.stat-icon.warning {
background-color: #FEF3C7;
color: #D97706;
}
.stat-icon.info {
background-color: #E0E7FF;
color: #4F46E5;
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.metric-card {
animation: fadeInUp 0.6s ease-out;
}
.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;
border-radius: 10px;
font-size: 18px;
}
.icon-wrapper.primary {
background-color: var(--primary-lighter);
color: var(--primary-color);
}
.icon-wrapper.success {
background-color: #D1FAE5;
color: #059669;
}
.icon-wrapper.warning {
background-color: #FEF3C7;
color: #D97706;
}
.icon-wrapper.info {
background-color: #E0E7FF;
color: #4F46E5;
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.metric-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.metric-main {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.metric-value {
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.metric-value.primary {
color: var(--primary-color);
}
.metric-value.success {
color: #059669;
}
.metric-value.warning {
color: #D97706;
}
.metric-value.danger {
color: #DC2626;
}
.metric-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.metric-divider {
height: 1px;
background-color: var(--border-light);
}
.metric-detail {
display: flex;
justify-content: space-around;
}
.detail-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.detail-label {
font-size: 12px;
color: var(--text-tertiary);
}
.detail-value {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.latency-grid {
display: flex;
justify-content: space-around;
padding: 8px 0;
}
.latency-item {
display: flex;
flex-direction: column;
align-items: center;
}
.latency-stats {
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-row {
display: flex;
justify-content: space-around;
}
.stat-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.stat-label {
font-size: 12px;
color: var(--text-tertiary);
}
.requests-grid {
display: flex;
justify-content: space-around;
padding: 8px 0;
}
.request-item {
display: flex;
flex-direction: column;
align-items: center;
}
.rate-stats {
display: flex;
justify-content: space-around;
}
.rate-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.rate-label {
font-size: 12px;
color: var(--text-tertiary);
}
.rate-value {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.rate-value.warning {
color: #D97706;
}
.rate-value.danger {
color: #DC2626;
}
.help-content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.help-item {
display: flex;
gap: 14px;
padding: 16px;
background-color: var(--bg-tertiary);
border-radius: 12px;
transition: all 0.2s ease;
}
.help-item:hover {
background-color: var(--primary-lighter);
}
.help-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
font-size: 18px;
flex-shrink: 0;
}
.help-icon.primary {
background-color: var(--primary-lighter);
color: var(--primary-color);
}
.help-icon.success {
background-color: #D1FAE5;
color: #059669;
}
.help-icon.warning {
background-color: #FEF3C7;
color: #D97706;
}
.help-icon.info {
background-color: #E0E7FF;
color: #4F46E5;
}
.help-text h4 {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.help-text p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
@media (max-width: 768px) {
.dashboard-page {
padding: 16px;
}
.page-title {
font-size: 20px;
}
.stat-value {
font-size: 24px;
}
.help-content {
grid-template-columns: 1fr;
}
.metric-value {
font-size: 28px;
}
}
</style>

View File

@ -1,48 +1,84 @@
<template>
<div class="kb-container">
<el-card>
<template #header>
<div class="card-header">
<span>知识库列表</span>
<el-button type="primary" @click="handleUploadClick">上传文档</el-button>
<div class="kb-page">
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">知识库管理</h1>
<p class="page-desc">上传文档并建立向量索引支持多种文档格式</p>
</div>
</template>
<div class="header-actions">
<el-button type="primary" @click="handleUploadClick">
<el-icon><Upload /></el-icon>
上传文档
</el-button>
</div>
</div>
</div>
<el-card shadow="hover" class="table-card">
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="name" label="文件名" />
<el-table-column prop="status" label="状态">
<el-table-column prop="name" label="文件名" min-width="200">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
<div class="file-name">
<el-icon class="file-icon"><Document /></el-icon>
<span>{{ scope.row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)" size="small">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="jobId" label="任务ID" width="120" />
<el-table-column prop="createTime" label="上传时间" />
<el-table-column label="操作" width="180">
<el-table-column prop="jobId" label="任务ID" width="180">
<template #default="scope">
<el-button link type="primary" @click="handleViewJob(scope.row)">查看详情</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
<span class="job-id">{{ scope.row.jobId || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="上传时间" width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="scope">
<el-button link type="primary" @click="handleViewJob(scope.row)">
<el-icon><View /></el-icon>
详情
</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="jobDialogVisible" title="索引任务详情" width="500px">
<el-dialog v-model="jobDialogVisible" title="索引任务详情" width="500px" class="job-dialog">
<el-descriptions :column="1" border v-if="currentJob">
<el-descriptions-item label="任务ID">{{ currentJob.jobId }}</el-descriptions-item>
<el-descriptions-item label="任务ID">
<span class="job-id">{{ currentJob.jobId }}</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(currentJob.status)">
<el-tag :type="getStatusType(currentJob.status)" size="small">
{{ getStatusText(currentJob.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="进度">{{ currentJob.progress }}%</el-descriptions-item>
<el-descriptions-item label="进度">
<div class="progress-wrapper">
<el-progress :percentage="currentJob.progress" :status="getProgressStatus(currentJob.status)" />
</div>
</el-descriptions-item>
<el-descriptions-item label="错误信息" v-if="currentJob.errorMsg">
<el-alert type="error" :closable="false">{{ currentJob.errorMsg }}</el-alert>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="jobDialogVisible = false">关闭</el-button>
<el-button v-if="currentJob?.status === 'pending' || currentJob?.status === 'processing'" type="primary" @click="refreshJobStatus">
<el-button
v-if="currentJob?.status === 'pending' || currentJob?.status === 'processing'"
type="primary"
@click="refreshJobStatus"
>
刷新状态
</el-button>
</template>
@ -55,6 +91,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Document, View, Delete } from '@element-plus/icons-vue'
import { uploadDocument, listDocuments, getIndexJob, deleteDocument } from '@/api/kb'
interface DocumentItem {
@ -92,6 +129,12 @@ const getStatusText = (status: string) => {
return textMap[status] || status
}
const getProgressStatus = (status: string) => {
if (status === 'completed') return 'success'
if (status === 'failed') return 'exception'
return undefined
}
const fetchDocuments = async () => {
try {
const res = await listDocuments({})
@ -227,6 +270,110 @@ const handleFileChange = async (event: Event) => {
</script>
<style scoped>
.kb-container { padding: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.kb-page {
padding: 24px;
min-height: calc(100vh - 60px);
}
.page-header {
margin-bottom: 24px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.title-section {
flex: 1;
min-width: 200px;
}
.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;
}
.header-actions {
display: flex;
align-items: center;
}
.table-card {
animation: fadeInUp 0.5s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.file-name {
display: flex;
align-items: center;
gap: 10px;
}
.file-icon {
color: var(--primary-color);
font-size: 18px;
}
.job-id {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
background-color: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 4px;
}
.progress-wrapper {
width: 100%;
}
.job-dialog :deep(.el-dialog__header) {
border-bottom: 1px solid var(--border-light);
}
.job-dialog :deep(.el-dialog__body) {
padding: 24px;
}
@media (max-width: 768px) {
.kb-page {
padding: 16px;
}
.page-title {
font-size: 20px;
}
.header-content {
flex-direction: column;
}
.title-section {
min-width: 100%;
}
}
</style>

View File

@ -1,8 +1,23 @@
<template>
<div class="rag-lab-container">
<el-row :gutter="20">
<el-col :span="10">
<el-card header="调试输入">
<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>
</div>
</template>
<el-form label-position="top">
<el-form-item label="查询 Query">
<el-input
@ -19,6 +34,8 @@
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"
@ -28,44 +45,77 @@
/>
</el-select>
</el-form-item>
<el-form-item label="LLM 模型">
<LLMSelector
v-model="queryParams.llmProvider"
:providers="llmProviders"
:loading="llmLoading"
:current-provider="currentLLMProvider"
placeholder="使用默认配置"
clearable
@change="handleLLMChange"
/>
</el-form-item>
<el-form-item label="参数配置">
<div class="param-item">
<span class="label">Top-K</span>
<el-input-number v-model="queryParams.params.topK" :min="1" :max="10" />
<el-input-number v-model="queryParams.topK" :min="1" :max="10" />
</div>
<div class="param-item">
<span class="label">Score Threshold</span>
<el-slider
v-model="queryParams.params.threshold"
v-model="queryParams.scoreThreshold"
:min="0"
:max="1"
:step="0.1"
show-input
/>
</div>
<div class="param-item">
<span class="label">生成 AI 回复</span>
<el-switch v-model="queryParams.generateResponse" />
</div>
<div class="param-item" v-if="queryParams.generateResponse">
<span class="label">流式输出</span>
<el-switch v-model="queryParams.streamOutput" />
</div>
</el-form-item>
<el-button type="primary" block @click="handleRun" :loading="loading">
运行实验
<el-button
type="primary"
block
@click="handleRun"
:loading="loading || streaming"
>
{{ 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 :span="14">
<el-tabs v-model="activeTab" type="border-card">
<el-col :xs="24" :sm="24" :md="14" :lg="14">
<el-tabs v-model="activeTab" type="border-card" class="result-tabs">
<el-tab-pane label="召回片段" name="retrieval">
<div v-if="results.retrievalResults.length === 0" class="placeholder-text">
<div v-if="retrievalResults.length === 0" class="placeholder-text">
暂无实验数据
</div>
<div v-else class="result-list">
<el-card
v-for="(item, index) in results.retrievalResults"
v-for="(item, index) in retrievalResults"
:key="index"
class="result-card"
shadow="never"
>
<div class="result-header">
<el-tag size="small">Score: {{ item.score.toFixed(4) }}</el-tag>
<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>
@ -73,19 +123,31 @@
</div>
</el-tab-pane>
<el-tab-pane label="最终 Prompt" name="prompt">
<div v-if="!results.finalPrompt" class="placeholder-text">
<div v-if="!finalPrompt" class="placeholder-text">
等待实验运行...
</div>
<div v-else class="prompt-view">
<pre><code>{{ results.finalPrompt }}</code></pre>
<pre><code>{{ finalPrompt }}</code></pre>
</div>
</el-tab-pane>
<el-tab-pane label="AI 回复" name="ai-response" v-if="queryParams.generateResponse">
<StreamOutput
v-if="queryParams.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="!results.diagnostics" class="placeholder-text">
<div v-if="!diagnostics" class="placeholder-text">
等待实验运行...
</div>
<div v-else class="diagnostics-view">
<pre><code>{{ JSON.stringify(results.diagnostics, null, 2) }}</code></pre>
<pre><code>{{ JSON.stringify(diagnostics, null, 2) }}</code></pre>
</div>
</el-tab-pane>
</el-tabs>
@ -95,10 +157,15 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { runRagExperiment } from '@/api/rag'
import { Edit } 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 AIResponseViewer from '@/components/rag/AIResponseViewer.vue'
import StreamOutput from '@/components/rag/StreamOutput.vue'
import LLMSelector from '@/components/rag/LLMSelector.vue'
interface KnowledgeBase {
id: string
@ -108,23 +175,33 @@ interface KnowledgeBase {
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 queryParams = reactive({
query: '',
kbIds: [] as string[],
params: {
topK: 3,
threshold: 0.5
}
llmProvider: '',
topK: 3,
scoreThreshold: 0.5,
generateResponse: true,
streamOutput: false
})
const results = reactive({
retrievalResults: [] as any[],
finalPrompt: '',
diagnostics: null as any
})
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)
let abortStream: (() => void) | null = null
const fetchKnowledgeBases = async () => {
kbLoading.value = true
@ -138,66 +215,328 @@ const fetchKnowledgeBases = async () => {
}
}
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) => {
queryParams.llmProvider = provider?.name || ''
}
const handleRun = async () => {
if (!queryParams.query.trim()) {
ElMessage.warning('请输入查询 Query')
return
}
clearResults()
if (queryParams.streamOutput && queryParams.generateResponse) {
await runStreamExperiment()
} else {
await runNormalExperiment()
}
}
const runNormalExperiment = async () => {
loading.value = true
try {
const res: any = await runRagExperiment(queryParams)
results.retrievalResults = res.retrievalResults || []
results.finalPrompt = res.finalPrompt || ''
results.diagnostics = res.diagnostics || null
activeTab.value = 'retrieval'
const res: any = await runRagExperiment({
query: queryParams.query,
kb_ids: queryParams.kbIds,
top_k: queryParams.topK,
score_threshold: queryParams.scoreThreshold,
llm_provider: queryParams.llmProvider || undefined,
generate_response: queryParams.generateResponse
})
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 (queryParams.generateResponse) {
activeTab.value = 'ai-response'
} else {
activeTab.value = 'retrieval'
}
ElMessage.success('实验运行成功')
} catch (err) {
} catch (err: any) {
console.error(err)
ElMessage.error('实验运行失败')
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: queryParams.query,
kb_ids: queryParams.kbIds,
top_k: queryParams.topK,
score_threshold: queryParams.scoreThreshold,
llm_provider: queryParams.llmProvider || 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-container { padding: 20px; }
.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;
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 10px;
margin-bottom: 16px;
gap: 16px;
}
.param-item .label {
width: 120px;
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;
}
.placeholder-text { color: #909399; text-align: center; padding: 50px 0; }
.result-list { height: 600px; overflow-y: auto; }
.result-card { margin-bottom: 15px; }
.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: 10px;
margin-bottom: 12px;
}
.source { font-size: 12px; color: #909399; }
.result-content { font-size: 14px; line-height: 1.6; color: #303133; }
.prompt-view, .diagnostics-view {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
.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 {
.prompt-view pre,
.diagnostics-view pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: monospace;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
}
@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%;
}
}
</style>

View File

@ -6,9 +6,9 @@ Provides overview statistics for the admin dashboard.
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from sqlalchemy import select, func
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
@ -21,6 +21,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/dashboard", tags=["Dashboard"])
LATENCY_THRESHOLD_MS = 5000
def get_current_tenant_id() -> str:
"""Dependency to get current tenant ID or raise exception."""
@ -44,6 +46,7 @@ def get_current_tenant_id() -> str:
async def get_dashboard_stats(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
latency_threshold: int = Query(default=LATENCY_THRESHOLD_MS, description="Latency threshold in ms"),
) -> JSONResponse:
"""
Get dashboard statistics including knowledge bases, messages, and activity.
@ -74,11 +77,126 @@ async def get_dashboard_stats(
session_result = await session.execute(session_count_stmt)
session_count = session_result.scalar() or 0
total_tokens_stmt = select(func.coalesce(func.sum(ChatMessage.total_tokens), 0)).select_from(
ChatMessage
).where(ChatMessage.tenant_id == tenant_id)
total_tokens_result = await session.execute(total_tokens_stmt)
total_tokens = total_tokens_result.scalar() or 0
prompt_tokens_stmt = select(func.coalesce(func.sum(ChatMessage.prompt_tokens), 0)).select_from(
ChatMessage
).where(ChatMessage.tenant_id == tenant_id)
prompt_tokens_result = await session.execute(prompt_tokens_stmt)
prompt_tokens = prompt_tokens_result.scalar() or 0
completion_tokens_stmt = select(func.coalesce(func.sum(ChatMessage.completion_tokens), 0)).select_from(
ChatMessage
).where(ChatMessage.tenant_id == tenant_id)
completion_tokens_result = await session.execute(completion_tokens_stmt)
completion_tokens = completion_tokens_result.scalar() or 0
ai_requests_stmt = select(func.count()).select_from(ChatMessage).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant"
)
ai_requests_result = await session.execute(ai_requests_stmt)
ai_requests_count = ai_requests_result.scalar() or 0
avg_latency_stmt = select(func.coalesce(func.avg(ChatMessage.latency_ms), 0)).select_from(
ChatMessage
).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant",
ChatMessage.latency_ms.isnot(None)
)
avg_latency_result = await session.execute(avg_latency_stmt)
avg_latency_ms = float(avg_latency_result.scalar() or 0)
last_request_stmt = select(ChatMessage.latency_ms, ChatMessage.created_at).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant"
).order_by(desc(ChatMessage.created_at)).limit(1)
last_request_result = await session.execute(last_request_stmt)
last_request_row = last_request_result.fetchone()
last_latency_ms = last_request_row[0] if last_request_row else None
last_request_time = last_request_row[1].isoformat() if last_request_row and last_request_row[1] else None
slow_requests_stmt = select(func.count()).select_from(ChatMessage).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant",
ChatMessage.latency_ms.isnot(None),
ChatMessage.latency_ms >= latency_threshold
)
slow_requests_result = await session.execute(slow_requests_stmt)
slow_requests_count = slow_requests_result.scalar() or 0
error_requests_stmt = select(func.count()).select_from(ChatMessage).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant",
ChatMessage.is_error == True
)
error_requests_result = await session.execute(error_requests_stmt)
error_requests_count = error_requests_result.scalar() or 0
p95_latency_stmt = select(func.coalesce(
func.percentile_cont(0.95).within_group(ChatMessage.latency_ms), 0
)).select_from(ChatMessage).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant",
ChatMessage.latency_ms.isnot(None)
)
p95_latency_result = await session.execute(p95_latency_stmt)
p95_latency_ms = float(p95_latency_result.scalar() or 0)
p99_latency_stmt = select(func.coalesce(
func.percentile_cont(0.99).within_group(ChatMessage.latency_ms), 0
)).select_from(ChatMessage).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant",
ChatMessage.latency_ms.isnot(None)
)
p99_latency_result = await session.execute(p99_latency_stmt)
p99_latency_ms = float(p99_latency_result.scalar() or 0)
min_latency_stmt = select(func.coalesce(func.min(ChatMessage.latency_ms), 0)).select_from(
ChatMessage
).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant",
ChatMessage.latency_ms.isnot(None)
)
min_latency_result = await session.execute(min_latency_stmt)
min_latency_ms = float(min_latency_result.scalar() or 0)
max_latency_stmt = select(func.coalesce(func.max(ChatMessage.latency_ms), 0)).select_from(
ChatMessage
).where(
ChatMessage.tenant_id == tenant_id,
ChatMessage.role == "assistant",
ChatMessage.latency_ms.isnot(None)
)
max_latency_result = await session.execute(max_latency_stmt)
max_latency_ms = float(max_latency_result.scalar() or 0)
return JSONResponse(
content={
"knowledgeBases": kb_count,
"totalMessages": msg_count,
"totalDocuments": doc_count,
"totalSessions": session_count,
"totalTokens": total_tokens,
"promptTokens": prompt_tokens,
"completionTokens": completion_tokens,
"aiRequestsCount": ai_requests_count,
"avgLatencyMs": round(avg_latency_ms, 2),
"lastLatencyMs": last_latency_ms,
"lastRequestTime": last_request_time,
"slowRequestsCount": slow_requests_count,
"errorRequestsCount": error_requests_count,
"p95LatencyMs": round(p95_latency_ms, 2),
"p99LatencyMs": round(p99_latency_ms, 2),
"minLatencyMs": round(min_latency_ms, 2),
"maxLatencyMs": round(max_latency_ms, 2),
"latencyThresholdMs": latency_threshold,
}
)

View File

@ -6,9 +6,8 @@ LLM Configuration Management API.
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Header, HTTPException
from app.core.tenant import get_tenant_id
from app.services.llm.factory import (
LLMConfigManager,
LLMProviderFactory,
@ -20,13 +19,21 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/llm", tags=["LLM Management"])
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
"""Extract tenant ID from header."""
if not x_tenant_id:
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
return x_tenant_id
@router.get("/providers")
async def list_providers(request: Request) -> dict[str, Any]:
async def list_providers(
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""
List all available LLM providers.
[AC-ASA-15] Returns provider list with configuration schemas.
"""
tenant_id = get_tenant_id(request)
logger.info(f"[AC-ASA-15] Listing LLM providers for tenant={tenant_id}")
providers = LLMProviderFactory.get_providers()
@ -44,12 +51,13 @@ async def list_providers(request: Request) -> dict[str, Any]:
@router.get("/config")
async def get_config(request: Request) -> dict[str, Any]:
async def get_config(
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""
Get current LLM configuration.
[AC-ASA-14] Returns current provider and config.
"""
tenant_id = get_tenant_id(request)
logger.info(f"[AC-ASA-14] Getting LLM config for tenant={tenant_id}")
manager = get_llm_config_manager()
@ -65,14 +73,13 @@ async def get_config(request: Request) -> dict[str, Any]:
@router.put("/config")
async def update_config(
request: Request,
body: dict[str, Any],
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""
Update LLM configuration.
[AC-ASA-16] Updates provider and config with validation.
"""
tenant_id = get_tenant_id(request)
provider = body.get("provider")
config = body.get("config", {})
@ -103,14 +110,13 @@ async def update_config(
@router.post("/test")
async def test_connection(
request: Request,
body: dict[str, Any] | None = None,
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""
Test LLM connection.
[AC-ASA-17, AC-ASA-18] Tests connection and returns response.
"""
tenant_id = get_tenant_id(request)
body = body or {}
test_prompt = body.get("test_prompt", "你好,请简单介绍一下自己。")

View File

@ -54,6 +54,13 @@ class ChatMessage(SQLModel, table=True):
session_id: str = Field(..., description="Session ID for conversation tracking", index=True)
role: str = Field(..., description="Message role: user or assistant")
content: str = Field(..., description="Message content")
prompt_tokens: int | None = Field(default=None, description="Number of prompt tokens used")
completion_tokens: int | None = Field(default=None, description="Number of completion tokens used")
total_tokens: int | None = Field(default=None, description="Total tokens used")
latency_ms: int | None = Field(default=None, description="Response latency in milliseconds")
first_token_ms: int | None = Field(default=None, description="Time to first token in milliseconds (for streaming)")
is_error: bool = Field(default=False, description="Whether this message is an error response")
error_message: str | None = Field(default=None, description="Error message if any")
created_at: datetime = Field(default_factory=datetime.utcnow, description="Message creation time")

View File

@ -30,32 +30,42 @@ LLM_PROVIDERS: dict[str, LLMProviderInfo] = {
display_name="OpenAI",
description="OpenAI GPT 系列模型 (GPT-4, GPT-3.5 等)",
config_schema={
"api_key": {
"type": "string",
"description": "API Key",
"required": True,
"secret": True,
},
"base_url": {
"type": "string",
"description": "API Base URL",
"default": "https://api.openai.com/v1",
},
"model": {
"type": "string",
"description": "模型名称",
"default": "gpt-4o-mini",
},
"max_tokens": {
"type": "integer",
"description": "最大输出 Token 数",
"default": 2048,
},
"temperature": {
"type": "number",
"description": "温度参数 (0-2)",
"default": 0.7,
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "API Key",
"required": True,
},
"base_url": {
"type": "string",
"title": "API Base URL",
"description": "API Base URL",
"default": "https://api.openai.com/v1",
},
"model": {
"type": "string",
"title": "模型名称",
"description": "模型名称",
"default": "gpt-4o-mini",
},
"max_tokens": {
"type": "integer",
"title": "最大输出 Token 数",
"description": "最大输出 Token 数",
"default": 2048,
},
"temperature": {
"type": "number",
"title": "温度参数",
"description": "温度参数 (0-2)",
"default": 0.7,
"minimum": 0,
"maximum": 2,
},
},
"required": ["api_key"],
},
),
"ollama": LLMProviderInfo(
@ -63,26 +73,79 @@ LLM_PROVIDERS: dict[str, LLMProviderInfo] = {
display_name="Ollama",
description="Ollama 本地模型 (Llama, Qwen 等)",
config_schema={
"base_url": {
"type": "string",
"description": "Ollama API 地址",
"default": "http://localhost:11434/v1",
"type": "object",
"properties": {
"base_url": {
"type": "string",
"title": "Ollama API 地址",
"description": "Ollama API 地址",
"default": "http://localhost:11434/v1",
},
"model": {
"type": "string",
"title": "模型名称",
"description": "模型名称",
"default": "llama3.2",
},
"max_tokens": {
"type": "integer",
"title": "最大输出 Token 数",
"description": "最大输出 Token 数",
"default": 2048,
},
"temperature": {
"type": "number",
"title": "温度参数",
"description": "温度参数 (0-2)",
"default": 0.7,
"minimum": 0,
"maximum": 2,
},
},
"model": {
"type": "string",
"description": "模型名称",
"default": "llama3.2",
},
"max_tokens": {
"type": "integer",
"description": "最大输出 Token 数",
"default": 2048,
},
"temperature": {
"type": "number",
"description": "温度参数 (0-2)",
"default": 0.7,
"required": [],
},
),
"deepseek": LLMProviderInfo(
name="deepseek",
display_name="DeepSeek",
description="DeepSeek 大模型 (deepseek-chat, deepseek-coder)",
config_schema={
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "DeepSeek API Key",
"required": True,
},
"base_url": {
"type": "string",
"title": "API Base URL",
"description": "API Base URL",
"default": "https://api.deepseek.com/v1",
},
"model": {
"type": "string",
"title": "模型名称",
"description": "模型名称 (deepseek-chat, deepseek-coder)",
"default": "deepseek-chat",
},
"max_tokens": {
"type": "integer",
"title": "最大输出 Token 数",
"description": "最大输出 Token 数",
"default": 2048,
},
"temperature": {
"type": "number",
"title": "温度参数",
"description": "温度参数 (0-2)",
"default": 0.7,
"minimum": 0,
"maximum": 2,
},
},
"required": ["api_key"],
},
),
"azure": LLMProviderInfo(
@ -90,37 +153,48 @@ LLM_PROVIDERS: dict[str, LLMProviderInfo] = {
display_name="Azure OpenAI",
description="Azure OpenAI 服务",
config_schema={
"api_key": {
"type": "string",
"description": "API Key",
"required": True,
"secret": True,
},
"base_url": {
"type": "string",
"description": "Azure Endpoint",
"required": True,
},
"model": {
"type": "string",
"description": "部署名称",
"required": True,
},
"api_version": {
"type": "string",
"description": "API 版本",
"default": "2024-02-15-preview",
},
"max_tokens": {
"type": "integer",
"description": "最大输出 Token 数",
"default": 2048,
},
"temperature": {
"type": "number",
"description": "温度参数 (0-2)",
"default": 0.7,
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "API Key",
"required": True,
},
"base_url": {
"type": "string",
"title": "Azure Endpoint",
"description": "Azure Endpoint",
"required": True,
},
"model": {
"type": "string",
"title": "部署名称",
"description": "部署名称",
"required": True,
},
"api_version": {
"type": "string",
"title": "API 版本",
"description": "API 版本",
"default": "2024-02-15-preview",
},
"max_tokens": {
"type": "integer",
"title": "最大输出 Token 数",
"description": "最大输出 Token 数",
"default": 2048,
},
"temperature": {
"type": "number",
"title": "温度参数",
"description": "温度参数 (0-2)",
"default": 0.7,
"minimum": 0,
"maximum": 2,
},
},
"required": ["api_key", "base_url", "model"],
},
),
}
@ -165,7 +239,7 @@ class LLMProviderFactory:
if provider not in LLM_PROVIDERS:
raise ValueError(f"Unsupported LLM provider: {provider}")
if provider in ("openai", "ollama", "azure"):
if provider in ("openai", "ollama", "azure", "deepseek"):
return OpenAIClient(
api_key=config.get("api_key"),
base_url=config.get("base_url"),
@ -187,8 +261,18 @@ class LLMConfigManager:
"""
def __init__(self):
self._current_provider: str = "openai"
self._current_config: dict[str, Any] = {}
from app.core.config import get_settings
settings = get_settings()
self._current_provider: str = settings.llm_provider
self._current_config: dict[str, Any] = {
"api_key": settings.llm_api_key,
"base_url": settings.llm_base_url,
"model": settings.llm_model,
"max_tokens": settings.llm_max_tokens,
"temperature": settings.llm_temperature,
}
self._client: LLMClient | None = None
def get_current_config(self) -> dict[str, Any]:
@ -236,13 +320,16 @@ class LLMConfigManager:
config: dict[str, Any],
) -> dict[str, Any]:
"""Validate configuration against provider schema."""
schema_props = provider_info.config_schema.get("properties", {})
required_fields = provider_info.config_schema.get("required", [])
validated = {}
for key, schema in provider_info.config_schema.items():
for key, prop_schema in schema_props.items():
if key in config:
validated[key] = config[key]
elif "default" in schema:
validated[key] = schema["default"]
elif schema.get("required"):
elif "default" in prop_schema:
validated[key] = prop_schema["default"]
elif key in required_fields:
raise ValueError(f"Missing required config: {key}")
return validated
@ -276,7 +363,9 @@ class LLMConfigManager:
import time
test_provider = provider or self._current_provider
test_config = config or self._current_config
test_config = config if config else self._current_config
logger.info(f"[AC-ASA-17] Test connection: provider={test_provider}, config={test_config}")
if test_provider not in LLM_PROVIDERS:
return {

View File

@ -1,9 +1,9 @@
---
module: ai-service-admin
feature: ASA
status: in_progress
status: completed
created: 2026-02-24
last_updated: "2026-02-24"
last_updated: "2026-02-25"
version: "0.3.0"
---
@ -13,7 +13,7 @@ version: "0.3.0"
- **module**: ai-service-admin
- **feature**: ASA
- **status**: 🔄进行中
- **status**: ✅已完成
## spec_references
@ -29,12 +29,12 @@ version: "0.3.0"
- [x] Phase 3: RAG 实验室 (100%) [P3-01 ~ P3-04]
- [x] Phase 4: 会话监控与详情 (100%) [P4-01 ~ P4-03]
- [x] Phase 5: 后端管理接口实现 (100%) [Backend Admin APIs]
- [ ] Phase 6: 嵌入模型管理 (0%) [P5-01 ~ P5-08]
- [ ] Phase 7: LLM 配置与 RAG 调试输出 (0%) [P6-01 ~ P6-10] 🔄当前
- [x] Phase 6: 嵌入模型管理 (100%) [P5-01 ~ P5-08]
- [x] Phase 7: LLM 配置与 RAG 调试输出 (100%) [P6-01 ~ P6-10]
## current_phase
**goal**: 实现 LLM 模型配置页面及 RAG 实验室 AI 输出调试功能
**goal**: ✅ 所有任务已完成
### sub_tasks
@ -49,40 +49,33 @@ version: "0.3.0"
- [x] (P4-01~P4-03) 会话监控功能
- [x] (P5-01~P5-06) 后端管理接口实现
#### Phase 6: 嵌入模型管理(待处理
#### Phase 6: 嵌入模型管理(已完成
- [x] (P5-01) API 服务层与类型定义 [AC-ASA-08, AC-ASA-09]
- [ ] (P5-02) 提供者选择组件 [AC-ASA-09]
- [ ] (P5-03) 动态配置表单 [AC-ASA-09, AC-ASA-10]
- [ ] (P5-04) 测试连接组件 [AC-ASA-11, AC-ASA-12]
- [ ] (P5-05) 支持格式组件 [AC-ASA-13]
- [ ] (P5-06) 页面骨架与路由 [AC-ASA-08]
- [ ] (P5-07) 配置加载与保存 [AC-ASA-08, AC-ASA-10]
- [ ] (P5-08) 组件整合与测试 [AC-ASA-08~AC-ASA-13]
- [x] (P5-02) 提供者选择组件 [AC-ASA-09]
- [x] (P5-03) 动态配置表单 [AC-ASA-09, AC-ASA-10]
- [x] (P5-04) 测试连接组件 [AC-ASA-11, AC-ASA-12]
- [x] (P5-05) 支持格式组件 [AC-ASA-13]
- [x] (P5-06) 页面骨架与路由 [AC-ASA-08]
- [x] (P5-07) 配置加载与保存 [AC-ASA-08, AC-ASA-10]
- [x] (P5-08) 组件整合与测试 [AC-ASA-08~AC-ASA-13]
#### Phase 7: LLM 配置与 RAG 调试输出(当前
#### Phase 7: LLM 配置与 RAG 调试输出(已完成
- [x] (P6-01) LLM API 服务层与类型定义:创建 src/api/llm.ts 和 src/types/llm.ts [AC-ASA-14, AC-ASA-15]
- [ ] (P6-02) LLM 提供者选择组件:创建 LLMProviderSelect.vue [AC-ASA-15]
- [ ] (P6-03) LLM 动态配置表单:创建 LLMConfigForm.vue [AC-ASA-15, AC-ASA-16]
- [ ] (P6-04) LLM 测试连接组件:创建 LLMTestPanel.vue [AC-ASA-17, AC-ASA-18]
- [ ] (P6-05) LLM 配置页面:创建 /admin/llm 页面 [AC-ASA-14, AC-ASA-16]
- [ ] (P6-06) AI 回复展示组件:创建 AIResponseViewer.vue [AC-ASA-19]
- [ ] (P6-07) 流式输出支持:实现 SSE 流式输出展示 [AC-ASA-20]
- [ ] (P6-08) Token 统计展示:展示 Token 消耗、响应耗时 [AC-ASA-21]
- [ ] (P6-09) LLM 选择器:在 RAG 实验室中添加 LLM 配置选择器 [AC-ASA-22]
- [ ] (P6-10) RAG 实验室整合:将 AI 输出组件整合到 RAG 实验室 [AC-ASA-19~AC-ASA-22]
- [x] (P6-02) LLM 提供者选择组件:创建通用 ProviderSelect.vue [AC-ASA-15]
- [x] (P6-03) LLM 动态配置表单:创建通用 ConfigForm.vue [AC-ASA-15, AC-ASA-16]
- [x] (P6-04) LLM 测试连接组件:创建通用 TestPanel.vue [AC-ASA-17, AC-ASA-18]
- [x] (P6-05) LLM 配置页面:创建 /admin/llm 页面 [AC-ASA-14, AC-ASA-16]
- [x] (P6-06) AI 回复展示组件:创建 AIResponseViewer.vue [AC-ASA-19]
- [x] (P6-07) 流式输出支持:实现 SSE 流式输出展示 [AC-ASA-20]
- [x] (P6-08) Token 统计展示:展示 Token 消耗、响应耗时 [AC-ASA-21]
- [x] (P6-09) LLM 选择器:在 RAG 实验室中添加 LLM 配置选择器 [AC-ASA-22]
- [x] (P6-10) RAG 实验室整合:将 AI 输出组件整合到 RAG 实验室 [AC-ASA-19~AC-ASA-22]
### next_action
**immediate**: 并行启动 3 个窗口执行 Phase 6 和 Phase 7 任务
**immediate**: 所有任务已完成,可进行代码提交
**details**:
- file: "ai-service-admin/src/"
- action: "窗口1: 嵌入管理组件; 窗口2: LLM 配置组件; 窗口3: RAG 实验室增强"
- reference: "spec/ai-service-admin/openapi.deps.yaml"
- constraints:
- 每个任务必须包含 AC 标记
- 完成后更新 spec/ai-service-admin/tasks.md
- commit message 格式: `feat(ASA-P6/P7): <desc> [AC-ASA-XX]`
**commit message**: `feat(ASA-P5,P6): 实现嵌入配置与LLM配置页面组件 [AC-ASA-09~AC-ASA-18]`
### backend_implementation_summary
@ -191,6 +184,49 @@ export const useTenantStore = defineStore('tenant', {
- spec/ai-service-admin/openapi.deps.yaml - 添加 LLM 配置接口和 RAG 实验增强接口
- docs/progress/ai-service-admin-progress.md - 添加 Phase 7
- session: "Session #5 (2026-02-25) - 嵌入配置与 LLM 配置页面组件实现"
completed:
- 创建通用提供者选择组件 ProviderSelect.vue
- 创建通用动态配置表单 ConfigForm.vue
- 创建通用测试连接组件 TestPanel.vue
- 创建 LLM API 服务层 src/api/llm.ts 和类型定义 src/types/llm.ts
- 创建 LLM Pinia Store src/stores/llm.ts
- 创建 LLM 配置页面 src/views/admin/llm/index.vue
- 添加 LLM 配置路由 /admin/llm
- 更新 tasks.md 和 progress.md 文档
changes:
- ai-service-admin/src/components/common/ProviderSelect.vue - 新增
- ai-service-admin/src/components/common/ConfigForm.vue - 新增
- ai-service-admin/src/components/common/TestPanel.vue - 新增
- ai-service-admin/src/api/llm.ts - 新增
- ai-service-admin/src/types/llm.ts - 新增
- ai-service-admin/src/stores/llm.ts - 新增
- ai-service-admin/src/views/admin/llm/index.vue - 新增
- ai-service-admin/src/router/index.ts - 添加 LLM 配置路由
- spec/ai-service-admin/tasks.md - 更新 P5-02~P5-08, P6-02~P6-05 状态
- docs/progress/ai-service-admin-progress.md - 更新进度
- session: "Session #5 (2026-02-25) - RAG 实验室 AI 输出增强组件"
completed:
- 创建 LLM API 服务层 src/api/llm.ts
- 更新 RAG API 服务层 src/api/rag.ts 添加流式输出支持
- 创建 RAG Store src/stores/rag.ts
- 创建 AI 回复展示组件 src/components/rag/AIResponseViewer.vue [AC-ASA-19, AC-ASA-21]
- 创建流式输出组件 src/components/rag/StreamOutput.vue [AC-ASA-20]
- 创建 LLM 选择器组件 src/components/rag/LLMSelector.vue [AC-ASA-22]
- 更新 RAG 实验室页面整合所有新组件 [AC-ASA-19~AC-ASA-22]
- 更新 tasks.md 和 progress.md 进度文档
changes:
- ai-service-admin/src/api/llm.ts - 新增
- ai-service-admin/src/api/rag.ts - 更新(添加流式输出支持)
- ai-service-admin/src/stores/rag.ts - 新增
- ai-service-admin/src/components/rag/AIResponseViewer.vue - 新增
- ai-service-admin/src/components/rag/StreamOutput.vue - 新增
- ai-service-admin/src/components/rag/LLMSelector.vue - 新增
- ai-service-admin/src/views/rag-lab/index.vue - 更新(整合 AI 输出组件)
- spec/ai-service-admin/tasks.md - 更新 P6-06~P6-10 状态
- docs/progress/ai-service-admin-progress.md - 更新进度
## startup_guide
1. **Step 1**: 读取本进度文档(了解当前位置与下一步)
@ -208,7 +244,7 @@ export const useTenantStore = defineStore('tenant', {
| Phase 3 | RAG 实验室 | 4 | ✅ 完成 |
| Phase 4 | 会话监控与详情 | 3 | ✅ 完成 |
| Phase 5 | 后端管理接口实现 | 6 | ✅ 完成 |
| Phase 6 | 嵌入模型管理 | 8 | ⏳ 待处理 |
| Phase 7 | LLM 配置与 RAG 调试输出 | 10 | 🔄 进行中 |
| Phase 6 | 嵌入模型管理 | 8 | ✅ 完成 |
| Phase 7 | LLM 配置与 RAG 调试输出 | 10 | ✅ 完成 |
**总计: 41 个任务 | 已完成: 23 个 | 待处理: 8 个 | 进行中: 10 个**
**总计: 41 个任务 | 已完成: 41 个 | 待处理: 0 个 | 进行中: 0 个**

View File

@ -126,25 +126,25 @@ principles:
- [x] (P5-01) API 服务层与类型定义:创建 src/api/embedding.ts 和 src/types/embedding.ts
- AC: [AC-ASA-08, AC-ASA-09]
- [ ] (P5-02) 提供者选择组件:实现 `EmbeddingProviderSelect` 下拉组件,对接 `/admin/embedding/providers`
- [x] (P5-02) 提供者选择组件:实现 `EmbeddingProviderSelect` 下拉组件,对接 `/admin/embedding/providers`
- AC: [AC-ASA-09]
- [ ] (P5-03) 动态配置表单:根据 `config_schema` 动态渲染配置表单,实现表单校验
- [x] (P5-03) 动态配置表单:根据 `config_schema` 动态渲染配置表单,实现表单校验
- AC: [AC-ASA-09, AC-ASA-10]
- [ ] (P5-04) 测试连接组件:实现 `EmbeddingTestPanel`,展示测试结果和错误信息
- [x] (P5-04) 测试连接组件:实现 `EmbeddingTestPanel`,展示测试结果和错误信息
- AC: [AC-ASA-11, AC-ASA-12]
- [ ] (P5-05) 支持格式组件:实现 `SupportedFormats`,展示支持的文档格式列表
- [x] (P5-05) 支持格式组件:实现 `SupportedFormats`,展示支持的文档格式列表
- AC: [AC-ASA-13]
- [ ] (P5-06) 页面骨架与路由:创建 `/admin/embedding` 页面,布局包含各功能区
- [x] (P5-06) 页面骨架与路由:创建 `/admin/embedding` 页面,布局包含各功能区
- AC: [AC-ASA-08]
- [ ] (P5-07) 配置加载与保存:实现配置加载、保存逻辑
- [x] (P5-07) 配置加载与保存:实现配置加载、保存逻辑
- AC: [AC-ASA-08, AC-ASA-10]
- [ ] (P5-08) 组件整合与测试:整合所有组件完成功能闭环
- [x] (P5-08) 组件整合与测试:整合所有组件完成功能闭环
- AC: [AC-ASA-08~AC-ASA-13]
---
@ -154,13 +154,13 @@ principles:
| 任务 | 描述 | 状态 |
|------|------|------|
| P5-01 | API 服务层与类型定义 | ✅ 已完成 |
| P5-02 | 提供者选择组件 | ⏳ 待处理 |
| P5-03 | 动态配置表单 | ⏳ 待处理 |
| P5-04 | 测试连接组件 | ⏳ 待处理 |
| P5-05 | 支持格式组件 | ⏳ 待处理 |
| P5-06 | 页面骨架与路由 | ⏳ 待处理 |
| P5-07 | 配置加载与保存 | ⏳ 待处理 |
| P5-08 | 组件整合与测试 | ⏳ 待处理 |
| P5-02 | 提供者选择组件 | ✅ 已完成 |
| P5-03 | 动态配置表单 | ✅ 已完成 |
| P5-04 | 测试连接组件 | ✅ 已完成 |
| P5-05 | 支持格式组件 | ✅ 已完成 |
| P5-06 | 页面骨架与路由 | ✅ 已完成 |
| P5-07 | 配置加载与保存 | ✅ 已完成 |
| P5-08 | 组件整合与测试 | ✅ 已完成 |
---
@ -173,33 +173,33 @@ principles:
- [x] (P6-01) LLM API 服务层与类型定义:创建 src/api/llm.ts 和 src/types/llm.ts
- AC: [AC-ASA-14, AC-ASA-15]
- [ ] (P6-02) LLM 提供者选择组件:实现 `LLMProviderSelect` 下拉组件
- [x] (P6-02) LLM 提供者选择组件:实现 `LLMProviderSelect` 下拉组件
- AC: [AC-ASA-15]
- [ ] (P6-03) LLM 动态配置表单:根据 `config_schema` 动态渲染配置表单
- [x] (P6-03) LLM 动态配置表单:根据 `config_schema` 动态渲染配置表单
- AC: [AC-ASA-15, AC-ASA-16]
- [ ] (P6-04) LLM 测试连接组件:实现 `LLMTestPanel`,展示测试回复和耗时
- [x] (P6-04) LLM 测试连接组件:实现 `LLMTestPanel`,展示测试回复和耗时
- AC: [AC-ASA-17, AC-ASA-18]
- [ ] (P6-05) LLM 配置页面:创建 `/admin/llm` 页面,整合所有组件
- [x] (P6-05) LLM 配置页面:创建 `/admin/llm` 页面,整合所有组件
- AC: [AC-ASA-14, AC-ASA-16]
### 6.2 RAG 实验室 AI 输出增强
- [ ] (P6-06) AI 回复展示组件:实现 `AIResponseViewer`,展示 AI 最终输出
- [x] (P6-06) AI 回复展示组件:实现 `AIResponseViewer`,展示 AI 最终输出
- AC: [AC-ASA-19]
- [ ] (P6-07) 流式输出支持:实现 SSE 流式输出展示,支持实时显示 AI 回复
- [x] (P6-07) 流式输出支持:实现 SSE 流式输出展示,支持实时显示 AI 回复
- AC: [AC-ASA-20]
- [ ] (P6-08) Token 统计展示:展示 Token 消耗、响应耗时等统计信息
- [x] (P6-08) Token 统计展示:展示 Token 消耗、响应耗时等统计信息
- AC: [AC-ASA-21]
- [ ] (P6-09) LLM 选择器:在 RAG 实验室中添加 LLM 配置选择器
- [x] (P6-09) LLM 选择器:在 RAG 实验室中添加 LLM 配置选择器
- AC: [AC-ASA-22]
- [ ] (P6-10) RAG 实验室整合:将 AI 输出组件整合到 RAG 实验室页面
- [x] (P6-10) RAG 实验室整合:将 AI 输出组件整合到 RAG 实验室页面
- AC: [AC-ASA-19~AC-ASA-22]
---
@ -209,12 +209,12 @@ principles:
| 任务 | 描述 | 状态 |
|------|------|------|
| P6-01 | LLM API 服务层与类型定义 | ✅ 已完成 |
| P6-02 | LLM 提供者选择组件 | ⏳ 待处理 |
| P6-03 | LLM 动态配置表单 | ⏳ 待处理 |
| P6-04 | LLM 测试连接组件 | ⏳ 待处理 |
| P6-05 | LLM 配置页面 | ⏳ 待处理 |
| P6-06 | AI 回复展示组件 | ⏳ 待处理 |
| P6-07 | 流式输出支持 | ⏳ 待处理 |
| P6-08 | Token 统计展示 | ⏳ 待处理 |
| P6-09 | LLM 选择器 | ⏳ 待处理 |
| P6-10 | RAG 实验室整合 | ⏳ 待处理 |
| P6-02 | LLM 提供者选择组件 | ✅ 已完成 |
| P6-03 | LLM 动态配置表单 | ✅ 已完成 |
| P6-04 | LLM 测试连接组件 | ✅ 已完成 |
| P6-05 | LLM 配置页面 | ✅ 已完成 |
| P6-06 | AI 回复展示组件 | ✅ 已完成 |
| P6-07 | 流式输出支持 | ✅ 已完成 |
| P6-08 | Token 统计展示 | ✅ 已完成 |
| P6-09 | LLM 选择器 | ✅ 已完成 |
| P6-10 | RAG 实验室整合 | ✅ 已完成 |