feat(llm): add UI to configure embedding models from any provider (#79)

This commit is contained in:
matevip 2026-05-17 09:25:09 +08:00
parent 7fb47390af
commit ce33c1c652
4 changed files with 343 additions and 47 deletions

View File

@ -22,6 +22,7 @@ import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
/**
* Embedding 模型工厂
@ -55,6 +56,14 @@ public class EmbeddingModelFactory {
/** 构造 API 时共享的 retry template用 Spring AI 默认) */
private static final RetryTemplate DEFAULT_RETRY = RetryUtils.DEFAULT_RETRY_TEMPLATE;
/**
* Trailing "/v{digits}" segment in a base URL the OpenAI-compatible
* convention (/v1 OpenAI, /v3 Volcano Ark, /v4 Zhipu). When the base URL
* already carries a version segment, the default "/v1/embeddings" path
* would build a broken URL like ".../api/paas/v4/v1/embeddings".
*/
private static final Pattern BASE_URL_VERSION_SUFFIX = Pattern.compile(".*/v\\d+$");
/** 按 modelConfig.id 缓存 EmbeddingModelconfig 变更时调用 {@link #evict} 清除 */
private final ConcurrentHashMap<Long, EmbeddingModel> cache = new ConcurrentHashMap<>();
@ -163,7 +172,7 @@ public class EmbeddingModelFactory {
OpenAiApi api = OpenAiApi.builder()
.baseUrl(baseUrl)
.apiKey(apiKey.trim())
.embeddingsPath("/v1/embeddings")
.embeddingsPath(resolveEmbeddingsPath(baseUrl))
.build();
OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder()
@ -181,4 +190,17 @@ public class EmbeddingModelFactory {
if (u.endsWith("/v1")) u = u.substring(0, u.length() - 3);
return u;
}
/**
* Resolve the embeddings path. Defaults to {@code /v1/embeddings}; when the
* base URL already ends with a version segment (Zhipu {@code /v4}, Volcano
* Ark {@code /v3}, ), drop the leading {@code /v1} so the request hits
* {@code <base>/embeddings} instead of a 404 at {@code <base>/v1/embeddings}.
*/
private String resolveEmbeddingsPath(String baseUrl) {
if (baseUrl != null && BASE_URL_VERSION_SUFFIX.matcher(baseUrl).matches()) {
return "/embeddings";
}
return "/v1/embeddings";
}
}

View File

@ -658,6 +658,36 @@ export default {
editTitle: 'Provider Settings',
manageTitle: 'Manage Models',
addModel: 'Add Model',
embedding: {
title: 'Embedding Models',
hint: 'Used for knowledge base semantic search; shares the provider API key with chat models',
loading: 'Loading...',
empty: 'No embedding models yet — add one with the form below.',
defaultBadge: 'Default',
builtinBadge: 'Built-in',
test: 'Test',
testing: 'Testing...',
testPassed: 'Passed · {dim} dimensions',
setDefault: 'Set as default',
delete: 'Delete',
deleteConfirm: 'Delete embedding model "{name}"?',
deleteFailed: 'Failed to delete',
deleted: 'Embedding model deleted',
addTitle: 'Add Embedding Model',
addHint: 'Pick a configured provider and enter an embedding model name it supports (e.g. text-embedding-3-small, bge-m3).',
provider: 'Provider',
selectProvider: 'Select a provider',
modelName: 'Model Name',
modelNamePlaceholder: 'e.g. text-embedding-3-small',
displayName: 'Display Name',
displayNamePlaceholder: 'Defaults to the model name',
add: 'Add',
adding: 'Adding...',
added: 'Embedding model added',
addFailed: 'Failed to add',
nameRequired: 'Select a provider and enter a model name',
noProviders: 'Enable and configure at least one provider above first.',
},
// Inline API Key on the provider card — primary entry point for the
// 90% case (paste a key, hit save). Modal stays for advanced fields.
inlineApiKeyTitle: 'API Key',

View File

@ -538,6 +538,36 @@ export default {
editTitle: '提供商配置',
manageTitle: '管理模型',
addModel: '新增模型',
embedding: {
title: 'Embedding 模型',
hint: '知识库语义检索使用,与 Chat 模型共享 Provider 的 API Key',
loading: '加载中...',
empty: '暂无 Embedding 模型,使用下方表单添加。',
defaultBadge: '默认',
builtinBadge: '内置',
test: '测试连通性',
testing: '测试中...',
testPassed: '测试通过 · 维度 {dim}',
setDefault: '设为系统默认',
delete: '删除',
deleteConfirm: '确认删除 Embedding 模型“{name}”?',
deleteFailed: '删除失败',
deleted: 'Embedding 模型已删除',
addTitle: '新增 Embedding 模型',
addHint: '选择一个已配置的提供商,填入它支持的 Embedding 模型名称(如 text-embedding-3-small、bge-m3。',
provider: '提供商',
selectProvider: '请选择提供商',
modelName: '模型名称',
modelNamePlaceholder: '例如 text-embedding-3-small',
displayName: '显示名称',
displayNamePlaceholder: '留空则使用模型名称',
add: '添加',
adding: '添加中...',
added: 'Embedding 模型已添加',
addFailed: '添加失败',
nameRequired: '请选择提供商并填写模型名称',
noProviders: '请先在上方启用并配置至少一个提供商。',
},
// Inline API Key on the provider card — primary entry point for the
// 90% case (paste a key, hit save). Modal stays for advanced fields.
inlineApiKeyTitle: 'API Key',

View File

@ -9,60 +9,128 @@
<line x1="14.83" y1="9.17" x2="19.07" y2="4.93"/>
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"/>
</svg>
Embedding 模型
<span class="group-hint">知识库语义检索使用 Chat 模型共享 Provider API Key</span>
{{ t('settings.model.embedding.title') }}
<span class="group-hint">{{ t('settings.model.embedding.hint') }}</span>
</h3>
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="models.length === 0" class="empty-state">
暂无可用的 Embedding 模型系统已预置 DashScope Text Embedding v3/v2
请在"云端模型"下的 <strong>DashScope</strong> Provider 中配置 API Key
</div>
<div v-if="loading" class="loading-state">{{ t('settings.model.embedding.loading') }}</div>
<div v-else class="embedding-grid">
<div v-for="model in models" :key="model.id" class="embedding-card">
<div class="embedding-card-header">
<div class="embedding-name">
{{ model.name }}
<span v-if="String(model.id) === defaultModelId" class="default-badge">默认</span>
<template v-else>
<div v-if="models.length === 0" class="empty-state">
{{ t('settings.model.embedding.empty') }}
</div>
<div v-else class="embedding-grid">
<div v-for="model in models" :key="model.id" class="embedding-card">
<div class="embedding-card-header">
<div class="embedding-name">
{{ model.name }}
<span v-if="String(model.id) === defaultModelId" class="default-badge">
{{ t('settings.model.embedding.defaultBadge') }}
</span>
<span v-if="model.builtin" class="builtin-badge">
{{ t('settings.model.embedding.builtinBadge') }}
</span>
</div>
<span class="provider-badge">{{ model.provider }}</span>
</div>
<span class="provider-badge">{{ model.provider }}</span>
</div>
<div class="embedding-model-id">{{ model.modelName }}</div>
<div v-if="model.description" class="embedding-desc">{{ model.description }}</div>
<div class="embedding-model-id">{{ model.modelName }}</div>
<div v-if="model.description" class="embedding-desc">{{ model.description }}</div>
<!-- 测试结果 -->
<div v-if="testResults[String(model.id)]" class="test-result" :class="testResults[String(model.id)].success ? 'success' : 'error'">
<span v-if="testResults[String(model.id)].success">
测试通过 · 维度 {{ testResults[String(model.id)].dimensions }}
</span>
<span v-else> {{ testResults[String(model.id)].message }}</span>
</div>
<!-- Connectivity test result -->
<div
v-if="testResults[String(model.id)]"
class="test-result"
:class="testResults[String(model.id)].success ? 'success' : 'error'"
>
<span v-if="testResults[String(model.id)].success">
{{ t('settings.model.embedding.testPassed', { dim: testResults[String(model.id)].dimensions }) }}
</span>
<span v-else> {{ testResults[String(model.id)].message }}</span>
</div>
<div class="embedding-actions">
<button
class="card-btn test-btn"
:disabled="testingId === String(model.id)"
@click="onTest(model)"
>
{{ testingId === String(model.id) ? '测试中...' : '测试连通性' }}
</button>
<button
v-if="String(model.id) !== defaultModelId"
class="card-btn"
@click="onSetDefault(model)"
>
设为系统默认
</button>
<div class="embedding-actions">
<button
class="card-btn test-btn"
:disabled="testingId === String(model.id)"
@click="onTest(model)"
>
{{ testingId === String(model.id) ? t('settings.model.embedding.testing') : t('settings.model.embedding.test') }}
</button>
<button
v-if="String(model.id) !== defaultModelId"
class="card-btn"
@click="onSetDefault(model)"
>
{{ t('settings.model.embedding.setDefault') }}
</button>
<button
v-if="!model.builtin"
class="card-btn danger"
@click="onDelete(model)"
>
{{ t('settings.model.embedding.delete') }}
</button>
</div>
</div>
</div>
</div>
<!-- Add embedding model -->
<div class="embedding-add-box">
<div class="add-box-title">{{ t('settings.model.embedding.addTitle') }}</div>
<div class="add-box-hint">{{ t('settings.model.embedding.addHint') }}</div>
<div v-if="providers.length === 0" class="no-providers">
{{ t('settings.model.embedding.noProviders') }}
</div>
<template v-else>
<div class="form-grid">
<div class="form-group">
<label class="form-label">{{ t('settings.model.embedding.provider') }}</label>
<select v-model="form.providerId" class="form-input">
<option value="" disabled>{{ t('settings.model.embedding.selectProvider') }}</option>
<option v-for="p in providers" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ t('settings.model.embedding.modelName') }}</label>
<input
v-model.trim="form.modelName"
class="form-input"
:placeholder="t('settings.model.embedding.modelNamePlaceholder')"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('settings.model.embedding.displayName') }}</label>
<input
v-model.trim="form.displayName"
class="form-input"
:placeholder="t('settings.model.embedding.displayNamePlaceholder')"
/>
</div>
</div>
<div class="add-actions">
<button
class="btn-primary"
:disabled="adding || !form.providerId || !form.modelName"
@click="onAdd"
>
{{ adding ? t('settings.model.embedding.adding') : t('settings.model.embedding.add') }}
</button>
</div>
</template>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { modelApi } from '@/api'
import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
interface EmbeddingModel {
id: string | number
@ -72,23 +140,41 @@ interface EmbeddingModel {
description?: string
enabled?: boolean
isDefault?: boolean
builtin?: boolean
}
interface ProviderOption {
id: string
name: string
}
const { t } = useI18n()
const models = ref<EmbeddingModel[]>([])
const providers = ref<ProviderOption[]>([])
const loading = ref(false)
const defaultModelId = ref<string>('')
const testingId = ref<string>('')
const adding = ref(false)
const testResults = ref<Record<string, { success: boolean; dimensions?: number; message?: string }>>({})
const form = reactive({
providerId: '',
modelName: '',
displayName: '',
})
async function loadAll() {
loading.value = true
try {
const [listRes, defaultRes] = await Promise.all([
const [listRes, defaultRes, providerRes] = await Promise.all([
modelApi.listByType('embedding'),
modelApi.getDefaultEmbedding(),
modelApi.listProviders(),
])
models.value = (listRes.data as any[]) || []
models.value = (listRes.data as EmbeddingModel[]) || []
defaultModelId.value = String((defaultRes.data as any)?.defaultModelId || '')
providers.value = ((providerRes.data as any[]) || []).map(p => ({ id: p.id, name: p.name }))
} catch (e: any) {
console.error('[EmbeddingModels] Load failed:', e?.message)
} finally {
@ -109,7 +195,7 @@ async function onTest(model: EmbeddingModel) {
} catch (e: any) {
testResults.value[String(model.id)] = {
success: false,
message: e?.message || '请求失败',
message: e?.message || t('settings.model.embedding.addFailed'),
}
} finally {
testingId.value = ''
@ -121,7 +207,58 @@ async function onSetDefault(model: EmbeddingModel) {
await modelApi.setDefaultEmbedding(model.id)
defaultModelId.value = String(model.id)
} catch (e: any) {
console.error('[EmbeddingModels] Set default failed:', e?.message)
mcToast.error(e?.message || t('settings.model.embedding.addFailed'))
}
}
async function onAdd() {
const modelName = form.modelName.trim()
if (!form.providerId || !modelName) {
mcToast.error(t('settings.model.embedding.nameRequired'))
return
}
adding.value = true
try {
await modelApi.create({
name: form.displayName.trim() || modelName,
provider: form.providerId,
modelName,
modelType: 'embedding',
description: '',
builtin: false,
isDefault: false,
enabled: true,
})
form.modelName = ''
form.displayName = ''
mcToast.success(t('settings.model.embedding.added'))
await loadAll()
} catch (e: any) {
mcToast.error(e?.message || t('settings.model.embedding.addFailed'))
} finally {
adding.value = false
}
}
async function onDelete(model: EmbeddingModel) {
const ok = await mcConfirm({
title: t('common.confirm'),
message: t('settings.model.embedding.deleteConfirm', { name: model.name }),
confirmText: t('settings.model.embedding.delete'),
tone: 'danger',
})
if (!ok) return
try {
await modelApi.delete(model.id)
// The deleted model may still be referenced as the system default clear
// it so wiki semantic search falls back instead of resolving a dead id.
if (String(model.id) === defaultModelId.value) {
await modelApi.setDefaultEmbedding('')
}
mcToast.success(t('settings.model.embedding.deleted'))
await loadAll()
} catch (e: any) {
mcToast.error(e?.message || t('settings.model.embedding.deleteFailed'))
}
}
@ -162,7 +299,6 @@ defineExpose({ refresh: loadAll })
background: var(--mc-bg-sunken);
border-radius: 8px;
}
.empty-state strong { color: var(--mc-primary); }
.embedding-grid {
display: grid;
@ -199,6 +335,14 @@ defineExpose({ refresh: loadAll })
border-radius: 4px;
font-weight: 600;
}
.builtin-badge {
font-size: 10px;
padding: 2px 6px;
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
border-radius: 4px;
font-weight: 600;
}
.provider-badge {
font-size: 11px;
padding: 2px 8px;
@ -251,4 +395,74 @@ defineExpose({ refresh: loadAll })
color: var(--mc-primary);
border-color: var(--mc-primary);
}
.card-btn.danger {
background: var(--mc-danger-bg);
color: var(--mc-danger);
border-color: var(--mc-danger);
}
.embedding-add-box {
margin-top: 16px;
padding: 16px;
border: 1px dashed var(--mc-border);
border-radius: 8px;
background: var(--mc-bg-sunken);
}
.add-box-title {
font-size: 14px;
font-weight: 600;
color: var(--mc-text-primary);
}
.add-box-hint {
margin-top: 4px;
font-size: 12px;
color: var(--mc-text-tertiary);
}
.no-providers {
margin-top: 12px;
font-size: 13px;
color: var(--mc-text-secondary);
}
.form-grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.form-label {
display: block;
font-size: 13px;
color: var(--mc-text-secondary);
margin-bottom: 6px;
}
.form-input {
width: 100%;
border: 1px solid var(--mc-border);
border-radius: 8px;
padding: 9px 12px;
font-size: 14px;
background: var(--mc-bg-surface);
color: var(--mc-text-primary);
}
.form-input:focus {
outline: none;
border-color: var(--mc-primary);
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1);
}
.add-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.btn-primary {
border: none;
border-radius: 8px;
padding: 9px 16px;
font-size: 14px;
cursor: pointer;
background: var(--mc-primary);
color: white;
}
.btn-primary:hover:not(:disabled) { background: var(--mc-primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
</style>