feat(frontend): 实现中英文切换与拼写检查
This commit is contained in:
@@ -4,14 +4,15 @@ import type { ModelBinding, ModelRoutingConfig, ModelRoutingResponse, ProviderCo
|
||||
import { getModelRouting, saveModelRouting } from '@/services/modelRoutingService'
|
||||
import { listProviders } from '@/services/providerService'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const capabilities: Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }> = [
|
||||
{ id: 'embedding', name: '向量嵌入 · Embedding', endpoint: '/embeddings', placeholder: '例如 text-embedding-3-small', local: '本地支持 Bekko / Granite,安装权重后可离线运行。' },
|
||||
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '本地采用 Qwen3-ASR 0.6B,默认 CPU。' },
|
||||
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '本地采用 ERes2NetV2,比对结果是相似度。' },
|
||||
]
|
||||
const capabilities = computed<Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }>>(() => [
|
||||
{ id: 'embedding', name: t('向量嵌入 · Embedding', 'Embedding'), endpoint: '/embeddings', placeholder: t('例如 text-embedding-3-small', 'For example, text-embedding-3-small'), local: t('本地支持 Bekko / Granite,安装权重后可离线运行。', 'Local Bekko / Granite can run offline after weights are installed.') },
|
||||
{ id: 'transcription', name: t('语音转写 · Transcription', 'Transcription'), endpoint: '/audio/transcriptions', placeholder: t('输入转写模型 ID', 'Enter a transcription model ID'), local: t('本地采用 Qwen3-ASR 0.6B,默认 CPU。', 'Local Qwen3-ASR 0.6B uses CPU by default.') },
|
||||
{ id: 'speaker_matching', name: t('说话人匹配 · Speaker matching', 'Speaker matching'), endpoint: '/audio/speaker-matches', placeholder: t('输入说话人匹配模型 ID', 'Enter a speaker matching model ID'), local: t('本地采用 ERes2NetV2,比对结果是相似度。', 'Local ERes2NetV2 returns a similarity score.') },
|
||||
])
|
||||
type Draft = { provider_id: string; model: string; endpoint: string; dimensions: string | number }
|
||||
const drafts = reactive(Object.fromEntries(capabilities.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
|
||||
const drafts = reactive(Object.fromEntries(capabilities.value.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
|
||||
const providers = ref<ProviderConfig[]>([])
|
||||
const response = ref<ModelRoutingResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -26,7 +27,7 @@ const unavailable = computed(() => providers.value.filter(provider => !eligible(
|
||||
const localBackend = (capability: RoutingCapability) => response.value?.local_backends.find(item => item.capability === capability)
|
||||
const localLabel = (capability: RoutingCapability) => {
|
||||
const status = localBackend(capability)?.status
|
||||
return status === 'ready' ? '已安装' : status === 'placeholder' ? '测试占位实现' : '未安装'
|
||||
return status === 'ready' ? t('已安装', 'Installed') : status === 'placeholder' ? t('测试占位实现', 'Test placeholder') : t('未安装', 'Not installed')
|
||||
}
|
||||
const protocols = [
|
||||
{ id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' },
|
||||
@@ -35,7 +36,7 @@ const protocols = [
|
||||
|
||||
function applyResponse(result: ModelRoutingResponse) {
|
||||
response.value = result
|
||||
for (const item of capabilities) {
|
||||
for (const item of capabilities.value) {
|
||||
const binding = result.config[item.id]
|
||||
Object.assign(drafts[item.id], { provider_id: binding?.provider_id ?? '', model: binding?.model ?? '', endpoint: binding?.endpoint ?? item.endpoint, dimensions: binding?.dimensions?.toString() ?? '' })
|
||||
}
|
||||
@@ -64,15 +65,15 @@ function changeProvider(capability: RoutingCapability) {
|
||||
const draft = drafts[capability]
|
||||
draft.model = ''
|
||||
draft.dimensions = ''
|
||||
draft.endpoint = capabilities.find(item => item.id === capability)!.endpoint
|
||||
draft.endpoint = capabilities.value.find(item => item.id === capability)!.endpoint
|
||||
saved.value = false
|
||||
}
|
||||
|
||||
function bindingFor(capability: RoutingCapability): ModelBinding | null {
|
||||
const draft = drafts[capability]
|
||||
if (!draft.provider_id) return null
|
||||
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error('请选择已启用且协议可用的提供商,或切换到本地。')
|
||||
if (!draft.model.trim()) throw new Error('请填写所选 API 的模型 ID。')
|
||||
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error(t('请选择已启用且协议可用的提供商,或切换到本地。', 'Select an enabled provider with a supported protocol, or switch to local.'))
|
||||
if (!draft.model.trim()) throw new Error(t('请填写所选 API 的模型 ID。', 'Enter the model ID for the selected API.'))
|
||||
if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。')
|
||||
const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint }
|
||||
if (capability === 'embedding') {
|
||||
@@ -107,39 +108,39 @@ async function save() {
|
||||
|
||||
<template>
|
||||
<section class="routing-settings" aria-labelledby="routing-title" :aria-busy="loading || saving">
|
||||
<div><h2 id="routing-title">能力模型路由</h2><p class="subtle">向量嵌入、语音转写和说话人匹配分别选择提供商与模型,独立于默认聊天模型。API Key 在「模型提供商」中管理。</p></div>
|
||||
<p class="subtle">未选择提供商即使用本地模型。API 请求失败、配置不可用或响应无效时回退到本地;使用前请下载对应权重并安装运行环境。</p>
|
||||
<p v-if="loading" role="status">正在加载模型路由…</p>
|
||||
<div><h2 id="routing-title">{{ t('能力模型路由', 'Capability model routing') }}</h2><p class="subtle">{{ t('向量嵌入、语音转写和说话人匹配分别选择提供商与模型,独立于默认聊天模型。API Key 在「模型提供商」中管理。', 'Choose providers and models separately for embeddings, transcription, and speaker matching. API keys are managed under Model Providers.') }}</p></div>
|
||||
<p class="subtle">{{ t('未选择提供商即使用本地模型。API 请求失败、配置不可用或响应无效时回退到本地;使用前请下载对应权重并安装运行环境。', 'With no provider selected, the local model is used. Failed API requests, invalid settings, or invalid responses fall back to local. Download the required weights and runtime first.') }}</p>
|
||||
<p v-if="loading" role="status">{{ t('正在加载模型路由…', 'Loading model routes…') }}</p>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? '放弃当前输入并加载最新配置' : response ? '重新加载(放弃未保存更改)' : '重试加载' }}</button><span v-if="response" class="subtle">配置版本 {{ response.config.version }}</span></div>
|
||||
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? t('放弃当前输入并加载最新配置', 'Discard input and load latest settings') : response ? t('重新加载(放弃未保存更改)', 'Reload (discard unsaved changes)') : t('重试加载', 'Retry loading') }}</button><span v-if="response" class="subtle">{{ t('配置版本', 'Configuration version') }} {{ response.config.version }}</span></div>
|
||||
<form v-if="response" @submit.prevent="save" @input="saved = false" @change="saved = false">
|
||||
<fieldset :disabled="loading || saving || conflict">
|
||||
<article v-for="capability in capabilities" :key="capability.id" class="routing-card" :data-capability="capability.id">
|
||||
<h3>{{ capability.name }}</h3>
|
||||
<p v-if="capability.id === 'embedding'" class="embedding-notice">保存配置或更换模型、接口后,请重建全部索引。配置成功不代表已有笔记的向量索引已更新;重建完成前可使用全文检索,混合检索会回退到全文检索。</p>
|
||||
<div class="protocols" aria-label="协议可用性">
|
||||
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? ' · 可用' : ' · 不可用' }}</span>
|
||||
<p v-if="capability.id === 'embedding'" class="embedding-notice">{{ t('保存配置或更换模型、接口后,请重建全部索引。配置成功不代表已有笔记的向量索引已更新;重建完成前可使用全文检索,混合检索会回退到全文检索。', 'Rebuild all indexes after saving or changing the model or endpoint. Saving settings does not update existing note vectors. Full-text search remains available, and hybrid search falls back to it until rebuilding completes.') }}</p>
|
||||
<div class="protocols" :aria-label="t('协议可用性', 'Protocol availability')">
|
||||
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? t(' · 可用', ' · Available') : t(' · 不可用', ' · Unavailable') }}</span>
|
||||
</div>
|
||||
<label class="field"><span>处理方式 / 提供商</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)">
|
||||
<option value="">本地 · {{ localLabel(capability.id) }}</option>
|
||||
<label class="field"><span>{{ t('处理方式 / 提供商', 'Processing / Provider') }}</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)">
|
||||
<option value="">{{ t('本地', 'Local') }} · {{ localLabel(capability.id) }}</option>
|
||||
<option v-for="provider in available" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }} · {{ provider.provider_type }}</option>
|
||||
<option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? '协议不可用' : '未启用' }}</option>
|
||||
<option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>原提供商已不可用 · {{ drafts[capability.id].provider_id }}</option>
|
||||
<option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? t('协议不可用', 'Protocol unavailable') : t('未启用', 'Disabled') }}</option>
|
||||
<option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>{{ t('原提供商已不可用', 'Previous provider is unavailable') }} · {{ drafts[capability.id].provider_id }}</option>
|
||||
</select></label>
|
||||
<div v-if="drafts[capability.id].provider_id" class="routing-fields">
|
||||
<label class="field"><span>模型 ID</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label>
|
||||
<label class="field"><span>Endpoint(相对 Base URL)</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label>
|
||||
<label v-if="capability.id === 'embedding'" class="field"><span>向量维度(可选)</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" placeholder="留空使用 API 默认维度" /><small class="subtle">填写模型支持的 1–16384 整数维度,或留空使用 API 默认值。</small></label>
|
||||
<label class="field"><span>{{ t('模型 ID', 'Model ID') }}</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label>
|
||||
<label class="field"><span>{{ t('Endpoint(相对 Base URL)', 'Endpoint (relative to Base URL)') }}</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label>
|
||||
<label v-if="capability.id === 'embedding'" class="field"><span>{{ t('向量维度(可选)', 'Vector dimensions (optional)') }}</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" :placeholder="t('留空使用 API 默认维度', 'Blank uses the API default')" /><small class="subtle">{{ t('填写模型支持的 1–16384 整数维度,或留空使用 API 默认值。', 'Enter an integer from 1 to 16384 supported by the model, or leave blank for the API default.') }}</small></label>
|
||||
</div>
|
||||
<p v-if="capability.id === 'speaker_matching'" class="subtle">说话人匹配使用本应用自定义 HTTP multipart 契约。该端点不是 OpenAI 标准接口;服务需实现对应的说话人匹配请求和响应。</p>
|
||||
<p v-if="capability.id === 'speaker_matching'" class="subtle">{{ t('说话人匹配使用本应用自定义 HTTP multipart 契约。该端点不是 OpenAI 标准接口;服务需实现对应的说话人匹配请求和响应。', 'Speaker matching uses this app’s custom HTTP multipart contract. It is not an OpenAI-standard endpoint; the service must implement the corresponding request and response.') }}</p>
|
||||
<div class="local-status" :class="{ selected: !drafts[capability.id].provider_id }">
|
||||
<strong>{{ drafts[capability.id].provider_id ? '本地回退状态' : '当前本地状态' }}</strong>
|
||||
<p>{{ localBackend(capability.id)?.status === 'ready' ? '本地后端已就绪。' : capability.local }}</p>
|
||||
<p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? '已就绪' : backend.status === 'placeholder' ? '占位实现' : '未安装 / 未接入' }}</span> {{ backend.message }}</p>
|
||||
<strong>{{ drafts[capability.id].provider_id ? t('本地回退状态', 'Local fallback status') : t('当前本地状态', 'Current local status') }}</strong>
|
||||
<p>{{ localBackend(capability.id)?.status === 'ready' ? t('本地后端已就绪。', 'The local backend is ready.') : capability.local }}</p>
|
||||
<p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? t('已就绪', 'Ready') : backend.status === 'placeholder' ? t('占位实现', 'Placeholder') : t('未安装 / 未接入', 'Not installed / connected') }}</span> {{ backend.message }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</fieldset>
|
||||
<div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? '保存中…' : '保存模型路由' }}</button><span v-if="saved" role="status">模型路由已保存。</span></div>
|
||||
<div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? t('保存中…', 'Saving…') : t('保存模型路由', 'Save model routes') }}</button><span v-if="saved" role="status">{{ t('模型路由已保存。', 'Model routes saved.') }}</span></div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user