mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(llm): add UI to configure embedding models from any provider (#79)
This commit is contained in:
parent
7fb47390af
commit
ce33c1c652
@ -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 缓存 EmbeddingModel,config 变更时调用 {@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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user