Files
Enterprise-Knowledge-Base/frontend/src/components/ModelEditorDialog.vue
T
wizardchen 6019c4b661 fix(frontend): polish model settings UI and fix disabled brand color
- Fix global `--td-brand-color-disabled` to use correct muted green instead of bright green
- Unify `variant="text"` buttons (refresh, download, check dimension) in model settings to inherit standard TDesign styles
- Adjust WeKnora Cloud credential status layout and change reconfigure button to outline
- Fix font size of external doc links in model settings hint blocks
- Increase font weight of KB chunking debug trigger for better visibility
2026-05-07 21:51:22 +08:00

1708 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<SettingDrawer
:visible="dialogVisible"
:title="isEdit ? $t('model.editor.editTitle') : $t('model.editor.addTitle')"
:description="getModalDescription()"
:confirm-loading="saving"
:confirm-disabled="formData.provider === 'weknoracloud' && wkcCredentialState !== 'configured'"
@update:visible="(v: boolean) => dialogVisible = v"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<t-form ref="formRef" :data="formData" :rules="rules" layout="vertical">
<!-- 模型来源 -->
<div class="form-item">
<label class="form-label required">{{ $t('model.editor.sourceLabel') }}</label>
<t-radio-group v-model="formData.source" class="source-select">
<t-radio-button
value="local"
:disabled="ollamaServiceStatus === false || modelType === 'rerank'"
>
{{ $t('model.editor.sourceLocal') }}
</t-radio-button>
<t-radio-button value="remote">{{ $t('model.editor.sourceRemote') }}</t-radio-button>
</t-radio-group>
<!-- ReRank模型不支持Ollama的提示信息 -->
<div v-if="modelType === 'rerank'" class="ollama-unavailable-tip rerank-tip">
<t-icon name="info-circle-filled" class="tip-icon info" />
<span class="tip-text">{{ $t('model.editor.ollamaNotSupportRerank') }}</span>
</div>
<!-- Ollama不可用时的提示信息 -->
<div v-else-if="ollamaServiceStatus === false" class="ollama-unavailable-tip">
<t-icon name="error-circle-filled" class="tip-icon" />
<span class="tip-text">{{ $t('model.editor.ollamaUnavailable') }}</span>
<t-button
variant="text"
size="small"
@click="goToOllamaSettings"
class="tip-link"
>
<template #icon><t-icon name="jump" /></template>
{{ $t('model.editor.goToOllamaSettings') }}
</t-button>
</div>
</div>
<!-- Ollama 本地模型选择器 -->
<div v-if="formData.source === 'local'" class="form-item">
<label class="form-label required">{{ $t('model.modelName') }}</label>
<div class="model-select-row">
<t-select
v-model="formData.modelName"
:loading="loadingOllamaModels"
:class="{ 'downloading': downloading }"
:style="downloading ? `--progress: ${downloadProgress}%` : ''"
filterable
:filter="handleModelFilter"
:placeholder="$t('model.searchPlaceholder')"
@focus="loadOllamaModels"
@visible-change="handleDropdownVisibleChange"
>
<!-- 已下载的模型 -->
<t-option
v-for="model in filteredOllamaModels"
:key="model.name"
:value="model.name"
:label="model.name"
>
<div class="model-option">
<t-icon name="check-circle-filled" class="downloaded-icon" />
<span class="model-name">{{ model.name }}</span>
<span class="model-size">{{ formatModelSize(model.size) }}</span>
</div>
</t-option>
<!-- 下载新模型选项(仅当搜索词不在列表中时显示) -->
<t-option
v-if="showDownloadOption"
:value="`__download__${searchKeyword}`"
:label="$t('model.editor.downloadLabel', { keyword: searchKeyword })"
class="download-option"
>
<div class="model-option download">
<t-icon name="download" class="download-icon" />
<span class="model-name">{{ $t('model.editor.downloadLabel', { keyword: searchKeyword }) }}</span>
</div>
</t-option>
<!-- 下载进度后缀 -->
<template v-if="downloading" #suffix>
<div class="download-suffix">
<t-icon name="loading" class="spinning" />
<span class="progress-text">{{ downloadProgress.toFixed(1) }}%</span>
</div>
</template>
</t-select>
<!-- 刷新按钮 -->
<t-button
variant="text"
size="small"
:loading="loadingOllamaModels"
@click="refreshOllamaModels"
class="refresh-btn"
>
<t-icon name="refresh" />
{{ $t('model.editor.refreshList') }}
</t-button>
</div>
</div>
<!-- Remote API 配置 -->
<template v-if="formData.source === 'remote'">
<!-- 厂商选择器 -->
<div class="form-item">
<label class="form-label">{{ $t('model.editor.providerLabel') }}</label>
<t-select
v-model="formData.provider"
:placeholder="$t('model.editor.providerPlaceholder')"
@change="handleProviderChange"
:popup-props="{ overlayClassName: 'provider-select-popup' }"
>
<t-option
v-for="opt in providerOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
<div class="provider-option">
<span class="provider-name">{{ opt.label }}</span>
<span class="provider-desc">{{ opt.description }}</span>
</div>
</t-option>
</t-select>
</div>
<!-- WeKnoraCloud 提示信息 -->
<template v-if="formData.provider === 'weknoracloud'">
<!-- 凭证已配置 -->
<div v-if="wkcCredentialState === 'configured'" class="weknoracloud-hint weknoracloud-hint--ok">
<t-icon name="check-circle-filled" style="font-size: 16px; color: var(--td-success-color); flex-shrink: 0;" />
<div>
{{ $t('settings.weknoraCloud.modelHintConfigured') }}
<a
href="https://developers.weixin.qq.com/doc/aispeech/knowledge/atomic_capability/atomic_interface.html"
target="_blank"
rel="noopener noreferrer"
class="doc-link"
>
{{ $t('settings.weknoraCloud.modelHintDocsLink') }}
<t-icon name="link" class="link-icon" />
</a>
</div>
</div>
<!-- 未配置 / 失效 -->
<div v-else-if="wkcCredentialState !== 'loading'" class="weknoracloud-hint weknoracloud-hint--warn">
<t-icon name="error-circle-filled" style="font-size: 16px; color: #f97316; flex-shrink: 0;" />
<div style="flex: 1;">
<template v-if="wkcCredentialState === 'expired'">
{{ $t('settings.weknoraCloud.credentialExpired') }}
</template>
<template v-else>
{{ $t('settings.weknoraCloud.credentialUnconfigured') }}
</template>
<div style="margin-top: 8px;">
<t-button
variant="text"
size="small"
@click="goToWeKnoraCloudSettings"
style="padding: 0; height: auto;"
>
<template #icon><t-icon name="jump" /></template>
{{ $t('settings.weknoraCloud.goToSettings') }}
</t-button>
</div>
</div>
</div>
<!-- 加载中 -->
<div v-else class="weknoracloud-hint">
<t-icon name="loading" class="spinning" style="font-size: 16px; color: var(--td-text-color-placeholder); flex-shrink: 0;" />
<span>{{ $t('settings.weknoraCloud.checkingStatus') }}</span>
</div>
</template>
<!-- 模型名称 -->
<div class="form-item">
<label class="form-label required">{{ $t('model.modelName') }}</label>
<t-input
v-model="formData.modelName"
:placeholder="getModelNamePlaceholder()"
:disabled="formData.provider === 'weknoracloud' && wkcCredentialState !== 'configured'"
/>
</div>
<div v-if="formData.provider !== 'weknoracloud'" class="form-item">
<label class="form-label required">{{ $t('model.editor.baseUrlLabel') }}</label>
<t-input
v-model="formData.baseUrl"
:placeholder="getBaseUrlPlaceholder()"
/>
</div>
<div v-if="formData.provider !== 'weknoracloud'" class="form-item">
<label class="form-label">{{ $t('model.editor.apiKeyOptional') }}</label>
<t-input
v-model="formData.apiKey"
type="password"
:placeholder="$t('model.editor.apiKeyPlaceholder')"
/>
</div>
<!-- 自定义 HTTP Header(类似 OpenAI Python SDK 的 extra_headers) -->
<div v-if="formData.provider !== 'weknoracloud'" class="form-item">
<div class="custom-headers-header">
<label class="form-label" style="margin-bottom: 0;">{{ $t('model.editor.customHeadersLabel') }}</label>
<t-button variant="text" size="small" theme="primary" @click="addCustomHeader">
<template #icon><t-icon name="add" /></template>
{{ $t('model.editor.customHeadersAdd') }}
</t-button>
</div>
<p class="form-desc custom-headers-desc">{{ $t('model.editor.customHeadersDesc') }}</p>
<div v-if="formData.customHeaders && formData.customHeaders.length > 0" class="custom-headers-list">
<div
v-for="(item, idx) in formData.customHeaders"
:key="idx"
class="custom-header-row"
>
<t-input
v-model="item.key"
:placeholder="$t('model.editor.customHeadersKeyPlaceholder')"
class="custom-header-key"
/>
<t-input
v-model="item.value"
:placeholder="$t('model.editor.customHeadersValuePlaceholder')"
class="custom-header-value"
/>
<t-button
variant="text"
shape="square"
size="small"
theme="danger"
@click="removeCustomHeader(idx)"
:aria-label="$t('common.delete')"
>
<t-icon name="close" />
</t-button>
</div>
</div>
</div>
<!-- Remote API 校验 -->
<div class="form-item">
<label class="form-label">{{ $t('model.editor.connectionTest') }}</label>
<div class="api-test-section">
<t-button
variant="outline"
@click="checkRemoteAPI"
:loading="checking"
:disabled="!formData.modelName || (!formData.baseUrl && formData.provider !== 'weknoracloud') || (formData.provider === 'weknoracloud' && wkcCredentialState !== 'configured')"
>
<template #icon>
<t-icon
v-if="!checking && remoteChecked && remoteAvailable"
name="check-circle-filled"
class="status-icon available"
/>
<t-icon
v-else-if="!checking && remoteChecked && !remoteAvailable"
name="close-circle-filled"
class="status-icon unavailable"
/>
</template>
{{ checking ? $t('model.editor.testing') : $t('model.editor.testConnection') }}
</t-button>
<span v-if="remoteChecked" :class="['test-message', remoteAvailable ? 'success' : 'error']">
{{ remoteMessage }}
</span>
</div>
</div>
</template>
<!-- Embedding 专用:维度 -->
<div v-if="modelType === 'embedding'" class="form-item">
<label class="form-label">{{ $t('model.editor.dimensionLabel') }}</label>
<div class="dimension-control">
<t-input
v-model.number="formData.dimension"
type="number"
:min="128"
:max="4096"
:placeholder="$t('model.editor.dimensionPlaceholder')"
:disabled="formData.source === 'local' && checking"
/>
<!-- Ollama 本地模型:自动检测维度按钮 -->
<t-button
v-if="formData.source === 'local' && formData.modelName"
variant="text"
size="small"
:loading="checking"
@click="checkOllamaDimension"
class="dimension-check-btn"
>
<t-icon name="refresh" />
{{ $t('model.editor.checkDimension') }}
</t-button>
</div>
<p v-if="dimensionChecked && dimensionMessage" class="dimension-hint" :class="{ success: dimensionSuccess }">
{{ dimensionMessage }}
</p>
</div>
<!-- Chat: supports vision toggle (VLLM models are inherently multimodal) -->
<div v-if="modelType === 'chat'" class="form-item">
<label class="form-label">{{ $t('model.editor.supportsVisionLabel') }}</label>
<div style="display: flex; align-items: center; gap: 8px;">
<t-switch v-model="formData.supportsVision" />
<span class="form-desc">{{ $t('model.editor.supportsVisionDesc') }}</span>
</div>
</div>
</t-form>
</SettingDrawer>
</template>
<script setup lang="ts">
import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { checkOllamaModels, checkRemoteModel, testEmbeddingModel, checkRerankModel, checkASRModel, listOllamaModels, downloadOllamaModel, getDownloadProgress, checkOllamaStatus, listModelProviders, type OllamaModelInfo, type ModelProviderOption } from '@/api/initialization'
import { getWeKnoraCloudStatus } from '@/api/model'
import { useI18n } from 'vue-i18n'
import { useUIStore } from '@/stores/ui'
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
interface CustomHeaderItem {
key: string
value: string
}
interface ModelFormData {
id: string
name: string
source: 'local' | 'remote'
provider?: string // Provider identifier: openai, aliyun, zhipu, generic, etc.
modelName: string
baseUrl?: string
apiKey?: string
dimension?: number
interfaceType?: 'ollama' | 'openai'
isDefault: boolean
supportsVision?: boolean
// 自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)
customHeaders?: CustomHeaderItem[]
}
interface Props {
visible: boolean
modelType: 'chat' | 'embedding' | 'rerank' | 'vllm' | 'asr'
modelData?: ModelFormData | null
}
const { t, te } = useI18n()
const uiStore = useUIStore()
const props = withDefaults(defineProps<Props>(), {
visible: false,
modelData: null
})
const emit = defineEmits<{
'update:visible': [value: boolean]
'confirm': [data: ModelFormData]
}>()
// API 返回的 Provider 列表
const apiProviderOptions = ref<ModelProviderOption[]>([])
const loadingProviders = ref(false)
// 硬编码的后备 Provider 配置 (当 API 不可用时使用)
const fallbackProviderOptions = computed(() => [
{
value: 'openai',
label: t('model.editor.providers.openai.label'),
defaultUrls: {
chat: 'https://api.openai.com/v1',
embedding: 'https://api.openai.com/v1',
rerank: 'https://api.openai.com/v1',
vllm: 'https://api.openai.com/v1',
asr: 'https://api.openai.com/v1'
},
description: t('model.editor.providers.openai.description'),
modelTypes: ['chat', 'embedding', 'vllm', 'asr']
},
{
value: 'azure_openai',
label: t('model.editor.providers.azure_openai.label'),
defaultUrls: {
chat: 'https://{resource}.openai.azure.com',
embedding: 'https://{resource}.openai.azure.com',
vllm: 'https://{resource}.openai.azure.com',
asr: 'https://{resource}.openai.azure.com'
},
description: t('model.editor.providers.azure_openai.description'),
modelTypes: ['chat', 'embedding', 'vllm', 'asr']
},
{
value: 'aliyun',
label: t('model.editor.providers.aliyun.label'),
defaultUrls: {
chat: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
embedding: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
rerank: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
vllm: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
},
description: t('model.editor.providers.aliyun.description'),
modelTypes: ['chat', 'embedding', 'rerank', 'vllm']
},
{
value: 'zhipu',
label: t('model.editor.providers.zhipu.label'),
defaultUrls: {
chat: 'https://open.bigmodel.cn/api/paas/v4',
embedding: 'https://open.bigmodel.cn/api/paas/v4/embeddings',
vllm: 'https://open.bigmodel.cn/api/paas/v4'
},
description: t('model.editor.providers.zhipu.description'),
modelTypes: ['chat', 'embedding', 'vllm']
},
{
value: 'openrouter',
label: t('model.editor.providers.openrouter.label'),
defaultUrls: {
chat: 'https://openrouter.ai/api/v1',
embedding: 'https://openrouter.ai/api/v1'
},
description: t('model.editor.providers.openrouter.description'),
modelTypes: ['chat', 'embedding']
},
{
value: 'siliconflow',
label: t('model.editor.providers.siliconflow.label'),
defaultUrls: {
chat: 'https://api.siliconflow.cn/v1',
embedding: 'https://api.siliconflow.cn/v1',
rerank: 'https://api.siliconflow.cn/v1'
},
description: t('model.editor.providers.siliconflow.description'),
modelTypes: ['chat', 'embedding', 'rerank']
},
{
value: 'jina',
label: t('model.editor.providers.jina.label'),
defaultUrls: {
embedding: 'https://api.jina.ai/v1',
rerank: 'https://api.jina.ai/v1'
},
description: t('model.editor.providers.jina.description'),
modelTypes: ['embedding', 'rerank']
},
{
value: 'nvidia',
label: t('model.editor.providers.nvidia.label'),
defaultUrls: {
chat: 'https://integrate.api.nvidia.com/v1',
embedding: 'https://integrate.api.nvidia.com/v1',
rerank: 'https://ai.api.nvidia.com/v1/retrieval/nvidia/reranking',
vllm: 'https://integrate.api.nvidia.com/v1',
},
description: t('model.editor.providers.nvidia.description'),
modelTypes: ['chat', 'embedding', 'rerank', 'vllm']
},
{
value: 'novita',
label: t('model.editor.providers.novita.label'),
defaultUrls: {
chat: 'https://api.novita.ai/openai/v1',
embedding: 'https://api.novita.ai/openai/v1',
vllm: 'https://api.novita.ai/openai/v1',
},
description: t('model.editor.providers.novita.description'),
modelTypes: ['chat', 'embedding', 'vllm']
},
{
value: 'generic',
label: t('model.editor.providers.generic.label'),
defaultUrls: {},
description: t('model.editor.providers.generic.description'),
modelTypes: ['chat', 'embedding', 'rerank', 'vllm', 'asr']
},
])
// 从 API 获取 Provider 列表
const loadProviders = async () => {
loadingProviders.value = true
try {
const providers = await listModelProviders(props.modelType)
if (providers.length > 0) {
apiProviderOptions.value = providers
}
} catch (error) {
console.error('Failed to load providers from API, using fallback', error)
} finally {
loadingProviders.value = false
}
}
// 根据当前模型类型过滤的 Provider 列表
// API 返回的 defaultUrls/modelTypes 数据优先,但 label/description 使用 i18n
const providerOptions = computed(() => {
// API 数据可用时,用 API 的结构数据 + i18n 的显示文本
if (apiProviderOptions.value.length > 0) {
return apiProviderOptions.value.map(p => ({
...p,
label: te(`model.editor.providers.${p.value}.label`)
? t(`model.editor.providers.${p.value}.label`)
: p.label,
description: te(`model.editor.providers.${p.value}.description`)
? t(`model.editor.providers.${p.value}.description`)
: p.description,
}))
}
// 回退到硬编码值,按 modelTypes 过滤
return fallbackProviderOptions.value.filter(p =>
p.modelTypes.includes(props.modelType)
)
})
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const isEdit = computed(() => !!props.modelData)
const formRef = ref()
const saving = ref(false)
const modelChecked = ref(false)
const modelAvailable = ref(false)
const checking = ref(false)
const remoteChecked = ref(false)
const remoteAvailable = ref(false)
const remoteMessage = ref('')
const dimensionChecked = ref(false)
const dimensionSuccess = ref(false)
const dimensionMessage = ref('')
// Ollama 模型状态
const ollamaModelList = ref<OllamaModelInfo[]>([])
const loadingOllamaModels = ref(false)
const searchKeyword = ref('')
const downloading = ref(false)
const downloadProgress = ref(0)
const currentDownloadModel = ref('')
let downloadInterval: any = null
// Ollama 服务状态
const ollamaServiceStatus = ref<boolean | null>(null)
const checkingOllamaStatus = ref(false)
// WeKnoraCloud 凭证状态
const wkcCredentialState = ref<'loading' | 'unconfigured' | 'configured' | 'expired'>('loading')
const checkWkcCredentialStatus = async () => {
wkcCredentialState.value = 'loading'
try {
const status = await getWeKnoraCloudStatus()
if (status.needs_reinit) {
wkcCredentialState.value = 'expired'
} else if (status.has_models) {
wkcCredentialState.value = 'configured'
} else {
wkcCredentialState.value = 'unconfigured'
}
} catch {
wkcCredentialState.value = 'unconfigured'
}
}
const goToWeKnoraCloudSettings = async () => {
emit('update:visible', false)
if (uiStore.showSettingsModal) {
uiStore.closeSettings()
await nextTick()
}
uiStore.openSettings('weknoracloud')
}
const formData = ref<ModelFormData>({
id: '',
name: '',
source: 'local',
provider: 'openai',
modelName: '',
baseUrl: '',
apiKey: '',
dimension: undefined,
interfaceType: 'ollama',
isDefault: false,
supportsVision: false,
customHeaders: []
})
const rules = computed(() => ({
modelName: [
{ required: true, message: t('model.editor.validation.modelNameRequired') },
{
validator: (val: string) => {
if (!val || !val.trim()) {
return { result: false, message: t('model.editor.validation.modelNameEmpty') }
}
if (val.trim().length > 100) {
return { result: false, message: t('model.editor.validation.modelNameMax') }
}
return { result: true }
},
trigger: 'blur'
}
],
baseUrl: [
{
required: true,
message: t('model.editor.validation.baseUrlRequired'),
trigger: 'blur'
},
{
validator: (val: string) => {
if (!val || !val.trim()) {
return { result: false, message: t('model.editor.validation.baseUrlEmpty') }
}
// 简单的 URL 格式校验
try {
new URL(val.trim())
return { result: true }
} catch {
return { result: false, message: t('model.editor.validation.baseUrlInvalid') }
}
},
trigger: 'blur'
}
]
}))
// 获取弹窗描述文字
const getModalDescription = () => {
const key = `model.editor.description.${props.modelType}` as const
return t(key) || t('model.editor.description.default')
}
// 获取模型名称占位符
const getModelNamePlaceholder = () => {
if (props.modelType === 'vllm') {
return formData.value.source === 'local'
? t('model.editor.modelNamePlaceholder.localVllm')
: t('model.editor.modelNamePlaceholder.remoteVllm')
}
if (props.modelType === 'asr') {
return t('model.editor.modelNamePlaceholder.remoteAsr')
}
return formData.value.source === 'local'
? t('model.editor.modelNamePlaceholder.local')
: t('model.editor.modelNamePlaceholder.remote')
}
const getBaseUrlPlaceholder = () => {
if (props.modelType === 'vllm') {
return t('model.editor.baseUrlPlaceholderVllm')
}
if (props.modelType === 'asr') {
return t('model.editor.baseUrlPlaceholderAsr')
}
return t('model.editor.baseUrlPlaceholder')
}
// 检查Ollama服务状态
const checkOllamaServiceStatus = async () => {
console.log('开始检查Ollama服务状态...')
checkingOllamaStatus.value = true
try {
const result = await checkOllamaStatus()
ollamaServiceStatus.value = result.available
console.log('Ollama服务状态检查完成:', result.available)
} catch (error) {
console.error('检查Ollama服务状态失败:', error)
ollamaServiceStatus.value = false
} finally {
checkingOllamaStatus.value = false
}
// Ollama 不可用时,新增场景下默认切换到 remote
if (ollamaServiceStatus.value === false && !isEdit.value && formData.value.source === 'local') {
formData.value.source = 'remote'
}
}
// 打开Ollama设置窗口
const goToOllamaSettings = async () => {
console.log('点击跳转到Ollama设置按钮')
// 关闭当前弹窗
emit('update:visible', false)
// 先关闭设置弹窗(如果已打开)
if (uiStore.showSettingsModal) {
uiStore.closeSettings()
// 等待 DOM 更新
await nextTick()
}
// 打开设置窗口并直接跳转到Ollama设置
console.log('调用uiStore.openSettings')
uiStore.openSettings('ollama')
console.log('uiStore.openSettings调用完成')
}
// 上一次打开时的 modelData id:用来判断切换模型/新增 vs. 同一次新增的连续打开
const lastOpenedModelId = ref<string | null>(null)
// 监听 visible 变化,初始化表单
watch(() => props.visible, (val) => {
if (val) {
// 检查Ollama服务状态
checkOllamaServiceStatus()
// 从 API 加载 Model Provider 列表
loadProviders()
// 每次打开都清理上一次遗留的校验/检测结果,避免编辑别的模型时
// 直接显示上一次的“连接成功”
modelChecked.value = false
modelAvailable.value = false
remoteChecked.value = false
remoteAvailable.value = false
remoteMessage.value = ''
dimensionChecked.value = false
dimensionSuccess.value = false
dimensionMessage.value = ''
const currentId = props.modelData?.id ?? null
if (props.modelData) {
// 编辑:始终用最新的 modelData 覆盖
formData.value = {
...props.modelData,
customHeaders: Array.isArray(props.modelData.customHeaders)
? props.modelData.customHeaders.map(h => ({ key: h.key, value: h.value }))
: []
}
} else if (lastOpenedModelId.value !== null || !formData.value.id) {
// 上次是编辑某个模型,或第一次新增 → 重置成空白
resetForm()
}
// 否则:连续两次"新增"打开(中间是点遮罩/ESC 关闭的)→ 保留上次填写
lastOpenedModelId.value = currentId
// ReRank 模型强制使用 remote 来源(Ollama 不支持 ReRank)
if (props.modelType === 'rerank') {
formData.value.source = 'remote'
}
// 如果当前 provider 是 WeKnoraCloud,检查凭证状态
if (formData.value.provider === 'weknoracloud') {
checkWkcCredentialStatus()
}
}
})
// 重置表单
const resetForm = () => {
formData.value = {
id: generateId(),
name: '', // 保留字段但不使用,保存时用 modelName
source: 'local',
provider: 'generic',
modelName: '',
baseUrl: '',
apiKey: '',
dimension: undefined, // 默认不填,让用户手动输入或通过检测按钮获取
interfaceType: undefined,
isDefault: false,
supportsVision: false,
customHeaders: []
}
modelChecked.value = false
modelAvailable.value = false
remoteChecked.value = false
remoteAvailable.value = false
remoteMessage.value = ''
dimensionChecked.value = false
dimensionSuccess.value = false
dimensionMessage.value = ''
}
// 处理厂商选择变化 (自动填充默认 URL)
const handleProviderChange = (value: string) => {
const provider = providerOptions.value.find(opt => opt.value === value)
if (provider && provider.defaultUrls) {
// 根据当前模型类型获取对应的默认 URL
const defaultUrl = provider.defaultUrls[props.modelType]
if (defaultUrl) {
formData.value.baseUrl = defaultUrl
}
// 重置校验状态
remoteChecked.value = false
remoteAvailable.value = false
remoteMessage.value = ''
}
// WeKnoraCloud: 检查凭证状态
if (value === 'weknoracloud') {
checkWkcCredentialStatus()
}
}
// 监听来源变化,重置校验状态(已合并到下面的 watch)
// 生成唯一ID
const generateId = () => {
return `model_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
// 自定义 HTTP Header 编辑
const addCustomHeader = () => {
if (!Array.isArray(formData.value.customHeaders)) {
formData.value.customHeaders = []
}
formData.value.customHeaders.push({ key: '', value: '' })
}
const removeCustomHeader = (idx: number) => {
if (!Array.isArray(formData.value.customHeaders)) return
formData.value.customHeaders.splice(idx, 1)
}
// 过滤后的模型列表
const filteredOllamaModels = computed(() => {
if (!searchKeyword.value) return ollamaModelList.value
return ollamaModelList.value.filter(model =>
model.name.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
// 是否显示"下载模型"选项
const showDownloadOption = computed(() => {
if (!searchKeyword.value.trim()) return false
// 检查搜索词是否已存在于模型列表中
const exists = ollamaModelList.value.some(model =>
model.name.toLowerCase() === searchKeyword.value.toLowerCase()
)
return !exists
})
// 自定义过滤逻辑(捕获搜索关键词)
const handleModelFilter = (filterWords: string) => {
searchKeyword.value = filterWords
return true // 让 TDesign 使用我们的 filteredOllamaModels
}
// 加载 Ollama 模型列表
const loadOllamaModels = async () => {
// 只在选择 local 来源时加载
if (formData.value.source !== 'local') return
loadingOllamaModels.value = true
try {
const models = await listOllamaModels()
ollamaModelList.value = models
} catch (error) {
console.error(t('model.editor.loadModelListFailed'), error)
MessagePlugin.error(t('model.editor.loadModelListFailed'))
} finally {
loadingOllamaModels.value = false
}
}
// 刷新模型列表
const refreshOllamaModels = async () => {
ollamaModelList.value = [] // 清空以强制重新加载
await loadOllamaModels()
MessagePlugin.success(t('model.editor.listRefreshed'))
}
// 监听下拉框可见性变化
const handleDropdownVisibleChange = (visible: boolean) => {
if (!visible) {
searchKeyword.value = ''
}
}
// 格式化模型大小
const formatModelSize = (bytes: number): string => {
if (!bytes || bytes === 0) return ''
const gb = bytes / (1024 * 1024 * 1024)
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 * 1024)).toFixed(0)} MB`
}
// 检查模型状态(Ollama本地模型)
const checkModelStatus = async () => {
if (!formData.value.modelName || formData.value.source !== 'local') {
return
}
try {
// 调用真实 Ollama API 检查模型是否存在
const result = await checkOllamaModels([formData.value.modelName])
modelChecked.value = true
modelAvailable.value = result.models[formData.value.modelName] || false
} catch (error) {
console.error('检查模型状态失败:', error)
modelChecked.value = false
modelAvailable.value = false
}
}
// 检查 Ollama 本地 Embedding 模型维度
const checkOllamaDimension = async () => {
if (!formData.value.modelName || formData.value.source !== 'local' || props.modelType !== 'embedding') {
return
}
checking.value = true
dimensionChecked.value = false
dimensionMessage.value = ''
try {
const result = await testEmbeddingModel({
source: 'local',
modelName: formData.value.modelName,
dimension: formData.value.dimension
})
dimensionChecked.value = true
dimensionSuccess.value = result.available || false
if (result.available && result.dimension) {
formData.value.dimension = result.dimension
dimensionMessage.value = t('model.editor.dimensionDetected', { value: result.dimension })
MessagePlugin.success(dimensionMessage.value)
} else {
if (result.message) {
console.debug('Backend dimension message:', result.message)
}
dimensionMessage.value = t('model.editor.dimensionFailed')
MessagePlugin.warning(dimensionMessage.value)
}
} catch (error: any) {
console.error('Ollama dimension check failed:', error)
dimensionChecked.value = true
dimensionSuccess.value = false
dimensionMessage.value = t('model.editor.dimensionFailed')
MessagePlugin.error(dimensionMessage.value)
} finally {
checking.value = false
}
}
// 检查 Remote API 连接(根据模型类型调用不同的接口)
const checkRemoteAPI = async () => {
if (!formData.value.modelName || (!formData.value.baseUrl && formData.value.provider !== 'weknoracloud')) {
MessagePlugin.warning(t('model.editor.fillModelAndUrl'))
return
}
checking.value = true
remoteChecked.value = false
remoteMessage.value = ''
try {
let result: any
// 把表单里 Key-Value 数组形式的自定义 Header 转成后端期望的 map。
// 跟 ModelSettings.vue 保存时一致,空行自动丢弃,保证测试连接与真正保存后的
// 生产调用使用完全相同的 Header 集合。
const customHeaders: Record<string, string> = {}
if (Array.isArray(formData.value.customHeaders)) {
for (const item of formData.value.customHeaders) {
const key = (item?.key ?? '').trim()
const value = (item?.value ?? '').trim()
if (key && value) customHeaders[key] = value
}
}
// 只在非空时带上字段,避免在 URL query / 日志里出现空对象
const headerPayload = Object.keys(customHeaders).length > 0
? { customHeaders }
: {}
// 根据模型类型调用不同的校验接口
switch (props.modelType) {
case 'chat':
// 对话模型(KnowledgeQA)
result = await checkRemoteModel({
modelName: formData.value.modelName,
baseUrl: formData.value.baseUrl,
apiKey: formData.value.apiKey || '',
provider: formData.value.provider,
...headerPayload,
})
break
case 'embedding':
// Embedding 模型
result = await testEmbeddingModel({
source: 'remote',
modelName: formData.value.modelName,
baseUrl: formData.value.baseUrl,
apiKey: formData.value.apiKey || '',
dimension: formData.value.dimension,
provider: formData.value.provider,
...headerPayload,
})
// 如果测试成功且返回了维度,自动填充
if (result.available && result.dimension) {
formData.value.dimension = result.dimension
MessagePlugin.info(t('model.editor.remoteDimensionDetected', { value: result.dimension }))
}
break
case 'rerank':
// Rerank 模型
result = await checkRerankModel({
modelName: formData.value.modelName,
baseUrl: formData.value.baseUrl,
apiKey: formData.value.apiKey || '',
provider: formData.value.provider,
...headerPayload,
})
break
case 'vllm':
// VLLM 模型(多模态)
// VLLM 使用 checkRemoteModel 进行基础连接测试
result = await checkRemoteModel({
modelName: formData.value.modelName,
baseUrl: formData.value.baseUrl,
apiKey: formData.value.apiKey || '',
provider: formData.value.provider,
...headerPayload,
})
break
case 'asr':
// ASR 模型(语音识别)— 使用专用的 ASR 测试接口(/v1/audio/transcriptions)
result = await checkASRModel({
modelName: formData.value.modelName,
baseUrl: formData.value.baseUrl,
apiKey: formData.value.apiKey || '',
provider: formData.value.provider,
...headerPayload,
})
break
default:
MessagePlugin.error(t('model.editor.unsupportedModelType'))
return
}
remoteChecked.value = true
remoteAvailable.value = result.available || false
// Always use i18n for display; backend message is for debugging only
if (result.message) {
console.debug('Backend message:', result.message)
}
remoteMessage.value = result.available
? t('model.editor.connectionSuccess')
: t('model.editor.connectionFailed')
if (result.available) {
MessagePlugin.success(remoteMessage.value)
} else {
MessagePlugin.error(remoteMessage.value)
}
} catch (error: any) {
console.error('Remote API check failed:', error)
remoteChecked.value = true
remoteAvailable.value = false
remoteMessage.value = t('model.editor.connectionConfigError')
MessagePlugin.error(remoteMessage.value)
} finally {
checking.value = false
}
}
// 确认保存
const handleConfirm = async () => {
try {
// 手动校验必填字段
if (!formData.value.modelName || !formData.value.modelName.trim()) {
MessagePlugin.warning(t('model.editor.validation.modelNameRequired'))
return
}
if (formData.value.modelName.trim().length > 100) {
MessagePlugin.warning(t('model.editor.validation.modelNameMax'))
return
}
// 如果是 remote 类型且非 WeKnoraCloud,必须填写 baseUrl
if (formData.value.source === 'remote' && formData.value.provider !== 'weknoracloud') {
if (!formData.value.baseUrl || !formData.value.baseUrl.trim()) {
MessagePlugin.warning(t('model.editor.remoteBaseUrlRequired'))
return
}
// 校验 Base URL 格式
try {
new URL(formData.value.baseUrl.trim())
} catch {
MessagePlugin.warning(t('model.editor.validation.baseUrlInvalid'))
return
}
}
// 执行表单验证
await formRef.value?.validate()
saving.value = true
// 如果是新增且没有 id,生成一个
if (!formData.value.id) {
formData.value.id = generateId()
}
emit('confirm', { ...formData.value })
dialogVisible.value = false
// 保存成功后重置草稿,下次打开新增模型时是空白
resetForm()
lastOpenedModelId.value = null
// 移除此处的成功提示,由父组件统一处理
} catch (error) {
console.error('表单验证失败:', error)
} finally {
saving.value = false
}
}
// 监听模型选择变化(处理下载逻辑和自动维度检测提示)
watch(() => formData.value.modelName, async (newValue, oldValue) => {
if (!newValue) return
// 处理下载逻辑
if (newValue.startsWith('__download__')) {
// 提取模型名称
const modelName = newValue.replace('__download__', '')
// 重置选择(避免显示 __download__ 前缀)
formData.value.modelName = ''
// 开始下载
await startDownload(modelName)
return
}
// 如果是 embedding 模型且选择的是 Ollama 本地模型,且模型名称发生了实际变化
if (props.modelType === 'embedding' &&
formData.value.source === 'local' &&
newValue !== oldValue &&
oldValue !== '') {
// 提示用户可以检测维度
MessagePlugin.info(t('model.editor.dimensionHint'))
}
})
// 开始下载模型
const startDownload = async (modelName: string) => {
downloading.value = true
downloadProgress.value = 0
currentDownloadModel.value = modelName
try {
// 启动下载
const result = await downloadOllamaModel(modelName)
const taskId = result.taskId
MessagePlugin.success(t('model.editor.downloadStarted', { name: modelName }))
// 轮询下载进度
downloadInterval = setInterval(async () => {
try {
const progress = await getDownloadProgress(taskId)
downloadProgress.value = progress.progress
if (progress.status === 'completed') {
// 下载完成
clearInterval(downloadInterval)
downloadInterval = null
downloading.value = false
MessagePlugin.success(t('model.editor.downloadCompleted', { name: modelName }))
// 刷新模型列表
await loadOllamaModels()
// 自动选中新下载的模型
formData.value.modelName = modelName
// 重置状态
downloadProgress.value = 0
currentDownloadModel.value = ''
} else if (progress.status === 'failed') {
// 下载失败
clearInterval(downloadInterval)
downloadInterval = null
downloading.value = false
MessagePlugin.error(progress.message || t('model.editor.downloadFailed', { name: modelName }))
downloadProgress.value = 0
currentDownloadModel.value = ''
}
} catch (error) {
console.error('获取下载进度失败:', error)
}
}, 1000) // 每秒查询一次
} catch (error: any) {
downloading.value = false
downloadProgress.value = 0
currentDownloadModel.value = ''
console.error('Download start failed:', error)
MessagePlugin.error(t('model.editor.downloadStartFailed'))
}
}
// 组件卸载时清理定时器
onUnmounted(() => {
if (downloadInterval) {
clearInterval(downloadInterval)
}
})
// 监听来源变化,清理所有状态
watch(() => formData.value.source, () => {
// 重置校验状态
modelChecked.value = false
modelAvailable.value = false
remoteChecked.value = false
remoteAvailable.value = false
remoteMessage.value = ''
dimensionChecked.value = false
dimensionSuccess.value = false
dimensionMessage.value = ''
// 清理下载状态
searchKeyword.value = ''
if (downloadInterval) {
clearInterval(downloadInterval)
downloadInterval = null
}
downloading.value = false
downloadProgress.value = 0
currentDownloadModel.value = ''
})
// 监听模型名称变化,清理维度检测状态
watch(() => formData.value.modelName, () => {
dimensionChecked.value = false
dimensionSuccess.value = false
dimensionMessage.value = ''
})
// 取消(点击底部"取消"按钮触发;点遮罩/ESC 不触发,从而保留草稿)
const handleCancel = () => {
resetForm()
lastOpenedModelId.value = null
dialogVisible.value = false
}
</script>
<style lang="less" scoped>
// 原生 t-form-item 容器置空(本组件使用自定义 .form-item + 手写 label)
:deep(.t-form) {
.t-form-item {
display: none;
}
}
// 表单项样式
.form-item {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
&.required::after {
content: '*';
color: var(--td-error-color);
margin-left: 4px;
font-weight: 500;
}
}
// 模型来源分段:两个选项等分宽度
.source-select {
display: flex;
width: 100%;
:deep(.t-radio-button) {
flex: 1;
text-align: center;
}
}
// 输入框样式:只在最外层 .t-input 上调字号,避免在内部 wrap/inner 上重复加边
// 与 border-radius,造成视觉上"嵌套圆角容器"的错觉
:deep(.t-input),
:deep(.t-select),
:deep(.t-textarea),
:deep(.t-input-number) {
width: 100%;
font-size: 13px;
}
// 厂商选择器样式 — 移至非 scoped 块,因为 t-select popup 渲染到 body 下
// .provider-option 样式见文件末尾
// 复选框
:deep(.t-checkbox) {
font-size: 13px;
.t-checkbox__label {
font-size: 13px;
color: var(--td-text-color-primary);
}
}
// API 测试区域
.api-test-section {
display: flex;
align-items: center;
gap: 12px;
.test-message {
font-size: 13px;
line-height: 1.5;
flex: 1;
&.success {
color: var(--td-brand-color-active);
}
&.error {
color: var(--td-error-color);
}
}
:deep(.t-button) {
min-width: 88px;
height: 32px;
font-size: 13px;
border-radius: 6px;
flex-shrink: 0;
}
.status-icon {
font-size: 16px;
flex-shrink: 0;
&.available {
color: var(--td-brand-color);
}
&.unavailable {
color: var(--td-error-color);
}
}
}
// WeKnoraCloud 提示信息
.weknoracloud-hint {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 20px;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
color: var(--td-text-color-secondary);
line-height: 1.5;
&--ok {
background: var(--td-success-color-light);
border: 1px solid var(--td-success-color-focus);
}
&--warn {
background: #fff7ed;
border: 1px solid #fed7aa;
border-left: 3px solid #f97316;
}
}
// Ollama 模型选择器样式
.model-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 0;
.downloaded-icon {
font-size: 14px;
color: var(--td-brand-color);
flex-shrink: 0;
}
.download-icon {
font-size: 14px;
color: var(--td-brand-color);
flex-shrink: 0;
}
.model-name {
flex: 1;
font-size: 13px;
color: var(--td-text-color-primary);
}
.model-size {
font-size: 12px;
color: var(--td-text-color-placeholder);
margin-left: auto;
}
&.download {
.model-name {
color: var(--td-brand-color);
font-weight: 500;
}
}
}
// 下载进度后缀样式
.download-suffix {
display: flex;
align-items: center;
gap: 4px;
padding: 0 4px;
.spinning {
animation: spin 1s linear infinite;
font-size: 14px;
color: var(--td-brand-color);
}
.progress-text {
font-size: 12px;
font-weight: 500;
color: var(--td-brand-color);
}
}
// 下载中的选择框进度条效果
:deep(.t-select.downloading) {
.t-input {
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--progress, 0%);
background: linear-gradient(90deg, rgba(7, 192, 95, 0.08), rgba(7, 192, 95, 0.15));
transition: width 0.3s ease;
z-index: 0;
border-radius: 5px 0 0 5px;
}
.t-input__inner,
input {
position: relative;
z-index: 1;
background: transparent !important;
}
}
}
.model-select-row {
display: flex;
align-items: center;
gap: 8px;
.t-select {
flex: 1;
}
}
.refresh-btn {
flex-shrink: 0;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// 维度控制样式
.dimension-control {
display: flex;
align-items: center;
gap: 8px;
:deep(.t-input) {
flex: 1;
}
}
.dimension-check-btn {
flex-shrink: 0;
}
.dimension-hint {
margin: 8px 0 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--td-error-color);
&.success {
color: var(--td-brand-color);
}
}
// 自定义 HTTP Header 区域
.custom-headers-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.custom-headers-desc {
margin: 0 0 10px 0;
font-size: 12px;
line-height: 1.5;
color: var(--td-text-color-placeholder);
}
.custom-headers-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.custom-header-row {
display: flex;
align-items: center;
gap: 8px;
.custom-header-key {
flex: 0 0 38%;
}
.custom-header-value {
flex: 1;
}
:deep(.t-button) {
flex-shrink: 0;
width: 32px;
height: 32px;
padding: 0;
}
}
.form-desc {
margin: 4px 0 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--td-text-color-placeholder);
}
// Ollama不可用提示样式
.ollama-unavailable-tip {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 12px;
background: var(--td-error-color-light);
border: 1px solid var(--td-error-color-focus);
border-radius: 6px;
font-size: 13px;
.tip-icon {
color: var(--td-error-color);
font-size: 16px;
flex-shrink: 0;
margin-right: 2px;
&.info {
color: var(--td-brand-color);
}
}
.tip-text {
color: var(--td-error-color);
flex: 1;
line-height: 1.5;
}
// ReRank提示使用主题绿色风格,与主页面保持一致
&.rerank-tip {
background: var(--td-success-color-light);
border: 1px solid var(--td-success-color-focus);
border-left: 3px solid var(--td-brand-color);
.tip-text {
color: var(--td-success-color);
}
}
:deep(.tip-link) {
color: var(--td-brand-color);
font-size: 13px;
font-weight: 500;
padding: 4px 6px 4px 10px !important;
min-height: auto !important;
height: auto !important;
line-height: 1.4 !important;
text-decoration: none;
white-space: nowrap;
display: inline-flex !important;
align-items: center !important;
gap: 1px;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background: rgba(7, 192, 95, 0.08) !important;
color: var(--td-brand-color-active) !important;
}
&:active {
background: rgba(7, 192, 95, 0.12) !important;
}
.t-icon {
font-size: 14px !important;
margin: 0 !important;
line-height: 1 !important;
display: inline-flex !important;
align-items: center !important;
}
}
}
</style>
<!-- 非 scoped 样式:t-select popup 渲染到 body 下,scoped 样式无法覆盖 -->
<style lang="less">
.provider-select-popup {
// 覆盖 TDesign option 默认固定高度,让两行内容正常展示
.t-select-option {
height: auto !important;
padding: 6px 12px;
border-radius: 6px;
margin: 0 4px;
outline: none;
&:focus,
&:focus-visible {
outline: none;
}
}
// 命中态:浅一点的底色,去掉默认的描边/反色
.t-select-option.t-is-selected {
background-color: var(--td-brand-color-light);
color: var(--td-text-color-primary);
font-weight: 500;
}
.provider-option {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
min-width: 0;
.provider-name {
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
line-height: 20px;
}
.provider-desc {
font-size: 12px;
color: var(--td-text-color-placeholder);
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
</style>