feat(ASA-P5): 实现动态配置表单与测试连接组件 [AC-ASA-09, AC-ASA-10, AC-ASA-11, AC-ASA-12]
This commit is contained in:
parent
c1d76093aa
commit
f2116b95f2
|
|
@ -0,0 +1,88 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export interface EmbeddingProviderInfo {
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
config_schema: Record<string, any>
|
||||
}
|
||||
|
||||
export interface EmbeddingConfig {
|
||||
provider: string
|
||||
config: Record<string, any>
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface EmbeddingConfigUpdate {
|
||||
provider: string
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface EmbeddingTestResult {
|
||||
success: boolean
|
||||
dimension: number
|
||||
latency_ms?: number
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface EmbeddingTestRequest {
|
||||
test_text?: string
|
||||
config?: EmbeddingConfigUpdate
|
||||
}
|
||||
|
||||
export interface DocumentFormat {
|
||||
extension: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface EmbeddingProvidersResponse {
|
||||
providers: EmbeddingProviderInfo[]
|
||||
}
|
||||
|
||||
export interface EmbeddingConfigUpdateResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface SupportedFormatsResponse {
|
||||
formats: DocumentFormat[]
|
||||
}
|
||||
|
||||
export function getProviders() {
|
||||
return request({
|
||||
url: '/admin/embedding/providers',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
return request({
|
||||
url: '/admin/embedding/config',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveConfig(data: EmbeddingConfigUpdate) {
|
||||
return request({
|
||||
url: '/admin/embedding/config',
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function testEmbedding(data: EmbeddingTestRequest): Promise<EmbeddingTestResult> {
|
||||
return request({
|
||||
url: '/admin/embedding/test',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getSupportedFormats() {
|
||||
return request({
|
||||
url: '/admin/embedding/formats',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
interface SchemaProperty {
|
||||
type: string
|
||||
title?: string
|
||||
description?: string
|
||||
default?: any
|
||||
enum?: string[]
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
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,230 @@
|
|||
<template>
|
||||
<el-card shadow="never" class="test-panel">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>连接测试</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="test-content">
|
||||
<el-form :model="testForm" label-width="80px">
|
||||
<el-form-item label="测试文本">
|
||||
<el-input
|
||||
v-model="testForm.test_text"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入测试文本(可选,默认使用系统预设文本)"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="!config?.provider"
|
||||
@click="handleTest"
|
||||
>
|
||||
<el-icon v-if="!loading"><Connection /></el-icon>
|
||||
{{ loading ? '测试中...' : '测试连接' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="testResult" class="test-result">
|
||||
<el-divider />
|
||||
|
||||
<el-alert
|
||||
v-if="testResult.success"
|
||||
:title="testResult.message || '连接成功'"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="result-alert"
|
||||
>
|
||||
<template #default>
|
||||
<div class="success-details">
|
||||
<div class="detail-item">
|
||||
<span class="label">向量维度:</span>
|
||||
<el-tag type="success">{{ testResult.dimension }}</el-tag>
|
||||
</div>
|
||||
<div v-if="testResult.latency_ms" class="detail-item">
|
||||
<span class="label">响应延迟:</span>
|
||||
<el-tag type="info">{{ testResult.latency_ms.toFixed(2) }} ms</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-alert
|
||||
v-else
|
||||
:title="testResult.error || '连接失败'"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="result-alert"
|
||||
>
|
||||
<template #default>
|
||||
<div class="error-details">
|
||||
<p class="error-message">{{ testResult.error || '未知错误' }}</p>
|
||||
<div class="troubleshooting">
|
||||
<p class="troubleshoot-title">排查建议:</p>
|
||||
<ul class="troubleshoot-list">
|
||||
<li v-for="(tip, index) in troubleshootingTips" :key="index">
|
||||
{{ tip }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Connection } from '@element-plus/icons-vue'
|
||||
import { testEmbedding, type EmbeddingConfigUpdate, type EmbeddingTestResult } from '@/api/embedding'
|
||||
|
||||
const props = defineProps<{
|
||||
config: EmbeddingConfigUpdate | null
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const testResult = ref<EmbeddingTestResult | null>(null)
|
||||
|
||||
const testForm = ref({
|
||||
test_text: ''
|
||||
})
|
||||
|
||||
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 () => {
|
||||
if (!props.config?.provider) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
const requestData: any = {
|
||||
config: props.config
|
||||
}
|
||||
if (testForm.value.test_text?.trim()) {
|
||||
requestData.test_text = testForm.value.test_text.trim()
|
||||
}
|
||||
|
||||
const result = await testEmbedding(requestData)
|
||||
testResult.value = result
|
||||
} catch (error: any) {
|
||||
testResult.value = {
|
||||
success: false,
|
||||
dimension: 0,
|
||||
error: error?.message || '请求失败,请检查网络连接'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-panel {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.test-content {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.result-alert {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.success-details {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-item .label {
|
||||
color: #606266;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f56c6c;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.troubleshooting {
|
||||
background-color: #fef0f0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.troubleshoot-title {
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.troubleshoot-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.troubleshoot-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
module: ai-service-admin
|
||||
title: "AI 中台管理界面(ai-service-admin)任务清单"
|
||||
status: "draft"
|
||||
version: "0.1.0"
|
||||
version: "0.2.0"
|
||||
owners:
|
||||
- "frontend"
|
||||
- "backend"
|
||||
|
|
@ -116,3 +116,48 @@ principles:
|
|||
- `/admin/rag/experiments/run`(POST 实验结果:retrievalResults + finalPrompt)
|
||||
- `/admin/sessions`(GET 列表,分页 + 筛选)
|
||||
- `/admin/sessions/{sessionId}`(GET 详情:messages + trace)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 嵌入模型管理(配置页面/测试连接)
|
||||
|
||||
> 页面导向:嵌入模型配置页面,支持提供者切换、参数配置、连接测试。
|
||||
|
||||
- [ ] (P5-01) 嵌入模型配置页面骨架:创建 `/admin/embedding` 路由,布局包含提供者选择区、配置表单区、测试连接区、支持格式展示区。
|
||||
- AC: [AC-ASA-08]
|
||||
|
||||
- [x] (P5-02) 提供者选择组件:实现 `EmbeddingProviderSelect` 下拉组件,对接 `/admin/embedding/providers`,展示提供者列表(name、display_name、description)。
|
||||
- AC: [AC-ASA-09]
|
||||
|
||||
- [x] (P5-03) 动态配置表单:根据选中提供者的 `config_schema` 动态渲染配置表单(支持 string、integer、number 类型),实现表单校验。
|
||||
- AC: [AC-ASA-09, AC-ASA-10]
|
||||
|
||||
- [ ] (P5-04) 当前配置加载:页面初始化时调用 `/admin/embedding/config` 获取当前配置,填充表单默认值。
|
||||
- AC: [AC-ASA-08]
|
||||
|
||||
- [ ] (P5-05) 配置保存功能:实现保存按钮,调用 `PUT /admin/embedding/config`,处理成功/失败响应并提示用户。
|
||||
- AC: [AC-ASA-10]
|
||||
|
||||
- [x] (P5-06) 测试连接功能:实现测试按钮,调用 `POST /admin/embedding/test`,展示测试结果(success、dimension、latency_ms、message)。
|
||||
- AC: [AC-ASA-11]
|
||||
|
||||
- [x] (P5-07) 测试失败错误展示:测试失败时展示详细错误信息(error 字段),并提供排查建议。
|
||||
- AC: [AC-ASA-12]
|
||||
|
||||
- [ ] (P5-08) 支持格式展示:调用 `/admin/embedding/formats` 获取支持的文档格式列表,以标签或卡片形式展示。
|
||||
- AC: [AC-ASA-13]
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 任务进度追踪
|
||||
|
||||
| 任务 | 描述 | 状态 |
|
||||
|------|------|------|
|
||||
| P5-01 | 嵌入模型配置页面骨架 | ⏳ 待处理 |
|
||||
| P5-02 | 提供者选择组件 | ✅ 已完成 |
|
||||
| P5-03 | 动态配置表单 | ✅ 已完成 |
|
||||
| P5-04 | 当前配置加载 | ⏳ 待处理 |
|
||||
| P5-05 | 配置保存功能 | ⏳ 待处理 |
|
||||
| P5-06 | 测试连接功能 | ✅ 已完成 |
|
||||
| P5-07 | 测试失败错误展示 | ✅ 已完成 |
|
||||
| P5-08 | 支持格式展示 | ⏳ 待处理 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue