[AC-AISVC-02, AC-AISVC-16] 多个需求合并 #1
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 模型配置' }
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,8 @@ export interface LLMTestResult {
|
|||
|
||||
export interface LLMTestRequest {
|
||||
test_prompt?: string
|
||||
config?: LLMConfigUpdate
|
||||
provider?: string
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface LLMProvidersResponse {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>上传文档并建立向量索引,支持 PDF、Word、TXT 等格式。</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>配置大语言模型,支持 OpenAI、DeepSeek、Ollama 等。</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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", "你好,请简单介绍一下自己。")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 个**
|
||||
|
|
|
|||
|
|
@ -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 实验室整合 | ✅ 已完成 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue