feat(provider): 完成阶段E协议适配、国内预设与模型路由

This commit is contained in:
2026-09-04 06:19:32 +08:00
parent 9b8b10cdb1
commit 1fe75e3fd2
57 changed files with 4208 additions and 592 deletions
@@ -0,0 +1,170 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelRoutingResponse, ProviderConfig } from '@/contracts'
import { ApiErrorClass } from '@/services/apiClient'
import * as service from '@/services/modelRoutingService'
import { listProviders } from '@/services/providerService'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
vi.mock('@/services/modelRoutingService', () => ({ getModelRouting: vi.fn(), saveModelRouting: vi.fn() }))
vi.mock('@/services/providerService', () => ({ listProviders: vi.fn() }))
const providers: ProviderConfig[] = [
{ provider_id: 'p1', provider_type: 'openai_compatible', name: 'Custom API', enabled: true, default_model: 'chat-model', capabilities: {}, has_credential: true },
{ provider_id: 'p2', provider_type: 'openai_chat', name: 'OpenAI', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'responses', provider_type: 'openai_responses', name: 'Responses', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'anthropic', provider_type: 'anthropic_messages', name: 'Anthropic', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'ollama', provider_type: 'ollama', name: 'Ollama', enabled: true, default_model: '', capabilities: {}, has_credential: false },
{ provider_id: 'disabled', provider_type: 'openai_chat', name: 'Disabled', enabled: false, default_model: '', capabilities: {}, has_credential: true },
]
const initial: ModelRoutingResponse = { config: { version: 3, embedding: null, transcription: null, speaker_matching: null }, local_backends: [
{ capability: 'embedding', status: 'placeholder', message: 'hash fallback' },
{ capability: 'transcription', status: 'not_installed', message: 'ASR not installed' },
{ capability: 'speaker_matching', status: 'not_installed', message: 'speaker not installed' },
] }
const wrappers: ReturnType<typeof mount>[] = []
async function render() {
const wrapper = mount(ModelRoutingSettings)
wrappers.push(wrapper)
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(service.getModelRouting).mockResolvedValue(structuredClone(initial))
vi.mocked(listProviders).mockResolvedValue(providers)
vi.mocked(service.saveModelRouting).mockImplementation(async config => ({ ...initial, config: { ...config, version: config.version + 1 } }))
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ModelRoutingSettings', () => {
it('loads local selections honestly, explains index rebuilds, and disables incompatible providers', async () => {
const wrapper = await render()
expect(wrapper.findAll('select').map(select => (select.element as HTMLSelectElement).value)).toEqual(['', '', ''])
expect(wrapper.text()).toContain('当前为占位实现')
expect(wrapper.text()).toContain('真实本地 ASR 尚未接入')
expect(wrapper.text()).toContain('真实本地说话人匹配尚未接入')
expect(wrapper.text()).toContain('重建全部')
expect(wrapper.text()).toContain('重建完成前继续使用本地检索')
expect(wrapper.text()).toContain('不是 OpenAI 标准接口')
for (const id of ['responses', 'anthropic', 'ollama', 'disabled']) expect(wrapper.get(`option[value="${id}"]`).attributes()).toHaveProperty('disabled')
expect(wrapper.get('option[value="p1"]').attributes()).not.toHaveProperty('disabled')
})
it('saves all three independent bindings with expected version and remote dimensions', async () => {
const wrapper = await render()
for (const capability of ['embedding', 'transcription', 'speaker_matching']) {
const card = wrapper.get(`[data-capability="${capability}"]`)
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue(`${capability}-model`)
}
await wrapper.get('[data-field="dimensions"]').setValue('3072')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenCalledWith({ version: 3,
embedding: { provider_id: 'p1', model: 'embedding-model', endpoint: '/embeddings', dimensions: 3072 },
transcription: { provider_id: 'p1', model: 'transcription-model', endpoint: '/audio/transcriptions' },
speaker_matching: { provider_id: 'p1', model: 'speaker_matching-model', endpoint: '/audio/speaker-matches' },
})
expect(wrapper.text()).toContain('配置版本 4')
expect(wrapper.text()).toContain('模型路由已保存')
await wrapper.get('[data-capability="embedding"] select').setValue('')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 4, embedding: null }))
})
it('supports omitted dimensions and clears stale models/endpoints when switching providers', async () => {
const wrapper = await render()
const card = wrapper.get('[data-capability="embedding"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('custom-embedding')
await card.get('[data-field="endpoint"]').setValue('/custom/embeddings')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenCalledWith(expect.objectContaining({ embedding: { provider_id: 'p1', model: 'custom-embedding', endpoint: '/custom/embeddings', dimensions: null } }))
await card.get('select').setValue('p2')
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
expect((card.get('[data-field="endpoint"]').element as HTMLInputElement).value).toBe('/embeddings')
})
it('shows a loading state and does not offer a default local configuration after load failure', async () => {
let fail!: (error: Error) => void
vi.mocked(service.getModelRouting).mockReturnValueOnce(new Promise((_, reject) => { fail = reject }))
const wrapper = await render()
expect(wrapper.text()).toContain('正在加载模型路由')
expect(wrapper.find('form').exists()).toBe(false)
fail(new Error('offline'))
await flushPromises()
expect(wrapper.text()).toContain('加载失败:offline')
expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('form').exists()).toBe(true)
})
it('keeps unsaved input on save failure and retries without inventing a new version', async () => {
const wrapper = await render()
const card = wrapper.get('[data-capability="transcription"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('asr-model')
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new Error('disk full'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('保存失败:disk full')
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('asr-model')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 3 }))
})
it('blocks overwrite after a conflict until explicitly reloading the latest configuration', async () => {
const wrapper = await render()
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new ApiErrorClass('MODEL_ROUTING_VERSION_CONFLICT', 'stale'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('配置版本冲突')
expect(wrapper.get('button[type="submit"]').attributes()).toHaveProperty('disabled')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).toHaveBeenCalledTimes(1)
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, version: 8 } })
await wrapper.findAll('button').find(button => button.text().includes('放弃当前输入'))!.trigger('click')
await flushPromises()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 8 }))
})
it('prevents invalid dimensions, endpoints, and missing provider bindings from being saved', async () => {
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, embedding: { provider_id: 'missing', model: 'old-model', endpoint: '/embeddings' } } })
const wrapper = await render()
expect(wrapper.text()).toContain('原提供商已不可用')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).not.toHaveBeenCalled()
const card = wrapper.get('[data-capability="embedding"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('embedding-model')
await card.get('[data-field="dimensions"]').setValue('1.5')
await wrapper.get('form').trigger('submit')
expect(wrapper.text()).toContain('嵌入维度必须为 116384 的整数')
await card.get('[data-field="dimensions"]').setValue('16385')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).not.toHaveBeenCalled()
expect(card.get('[data-field="dimensions"]').attributes('max')).toBe('16384')
await card.get('[data-field="dimensions"]').setValue('')
await card.get('[data-field="endpoint"]').setValue('https://example.test/embeddings')
await wrapper.get('form').trigger('submit')
expect(wrapper.text()).toContain('Endpoint 必须是以 / 开头的相对路径')
expect(service.saveModelRouting).not.toHaveBeenCalled()
})
it('labels injected ready local backends accurately', async () => {
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, local_backends: [{ capability: 'transcription', status: 'ready', message: 'Local ASR ready' }] })
const wrapper = await render()
const card = wrapper.get('[data-capability="transcription"]')
expect(card.get('option[value=""]').text()).toBe('本地 · 已就绪')
expect(card.text()).toContain('本地后端已就绪')
expect(card.text()).toContain('Local ASR ready')
expect(card.text()).not.toContain('真实本地 ASR 尚未接入')
})
})
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { ModelBinding, ModelRoutingConfig, ModelRoutingResponse, ProviderConfig, RoutingCapability } from '@/contracts'
import { getModelRouting, saveModelRouting } from '@/services/modelRoutingService'
import { listProviders } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient'
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: '当前为占位实现,尚未接入真实本地嵌入模型。' },
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '真实本地 ASR 尚未接入,等待阶段 F;当前无法进行本地语音识别。' },
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '真实本地说话人匹配尚未接入,等待阶段 F;当前无法进行本地声纹匹配。' },
]
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 providers = ref<ProviderConfig[]>([])
const response = ref<ModelRoutingResponse | null>(null)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const saved = ref(false)
const conflict = ref(false)
let active = true
const eligible = (provider: ProviderConfig) => provider.enabled && ['openai_chat', 'openai_compatible'].includes(provider.provider_type)
const available = computed(() => providers.value.filter(eligible))
const unavailable = computed(() => providers.value.filter(provider => !eligible(provider)))
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' ? '占位实现' : '尚未接入'
}
const protocols = [
{ id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' },
{ id: 'openai_responses', label: 'Responses' }, { id: 'anthropic_messages', label: 'Anthropic' }, { id: 'ollama', label: 'Ollama' },
]
function applyResponse(result: ModelRoutingResponse) {
response.value = result
for (const item of capabilities) {
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() ?? '' })
}
}
async function load() {
if (loading.value || saving.value) return
loading.value = true
error.value = ''
saved.value = false
try {
const [routing, items] = await Promise.all([getModelRouting(), listProviders()])
if (!active) return
providers.value = items
applyResponse(routing)
conflict.value = false
} catch (reason) {
if (active) error.value = `加载失败:${reason instanceof Error ? reason.message : '无法读取模型路由或提供商'}`
} finally { loading.value = false }
}
onMounted(load)
onBeforeUnmount(() => { active = false })
function changeProvider(capability: RoutingCapability) {
const draft = drafts[capability]
draft.model = ''
draft.dimensions = ''
draft.endpoint = capabilities.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 (!/^\/[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') {
const dimension = String(draft.dimensions).trim()
if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。')
binding.dimensions = dimension ? Number(dimension) : null
}
return binding
}
async function save() {
if (!response.value || loading.value || saving.value || conflict.value) return
saving.value = true
error.value = ''
saved.value = false
try {
const config: ModelRoutingConfig = {
version: response.value.config.version,
embedding: bindingFor('embedding'), transcription: bindingFor('transcription'), speaker_matching: bindingFor('speaker_matching'),
}
const result = await saveModelRouting(config)
if (active) { applyResponse(result); saved.value = true }
} catch (reason) {
if (!active) return
conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code)
error.value = conflict.value
? '配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。'
: `保存失败:${reason instanceof Error ? reason.message : '请重试'}`
} finally { saving.value = false }
}
</script>
<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 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>
<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>
</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>
<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>
</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">填写模型支持的 116384 整数维度或留空使用 API 默认值</small></label>
</div>
<p v-if="capability.id === 'speaker_matching'" class="subtle">说话人匹配使用本应用自定义 HTTP multipart 契约该端点不是 OpenAI 标准接口服务需实现对应的说话人匹配请求和响应</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>
</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>
</form>
</section>
</template>
<style scoped>
.routing-settings, form, fieldset { display: grid; gap: var(--space-lg); }
.routing-settings { border-top: 1px solid var(--color-border-default); padding-top: var(--space-xl); margin-top: var(--space-md); }
fieldset { min-width: 0; padding: 0; margin: 0; border: 0; }
.routing-card { display: grid; gap: var(--space-md); padding: var(--space-lg); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-primary); }
.routing-card h3 { margin: 0; }
.protocols { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
.protocol-unavailable { opacity: .65; }
.embedding-notice { padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-accent-soft); color: var(--color-text-primary); }
.routing-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-md); }
.local-status { display: grid; gap: var(--space-xs); padding: var(--space-md); background: var(--color-background-secondary); border-radius: var(--radius-md); }
.local-status.selected { border-left: 3px solid var(--color-accent-primary); }
@media (max-width: 700px) { .routing-fields { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,157 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProviderConfig, ProviderPreset } from '@/contracts'
import * as service from '@/services/providerService'
import ProviderForm from './ProviderForm.vue'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() }))
const presets: ProviderPreset[] = [
{ preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' },
{ preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' },
]
const existing: ProviderConfig = { provider_id: 'p1', provider_type: 'openai_compatible', name: 'DeepSeek', base_url: presets[0].base_url, default_model: 'old-model', enabled: true, credential_id: 'old-shared-key', has_credential: true, capabilities: {} }
const wrappers: ReturnType<typeof mount>[] = []
async function render(provider?: ProviderConfig) {
const wrapper = mount(ProviderForm, { props: { provider, models: [{ model_id: 'old-model', name: 'Old', capabilities: {} }] } })
wrappers.push(wrapper)
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(service.listProviderPresets).mockResolvedValue(presets)
vi.mocked(service.getCredentialStatus).mockResolvedValue(true)
vi.mocked(service.putCredential).mockResolvedValue()
vi.mocked(service.createProvider).mockResolvedValue(existing)
vi.mocked(service.updateProvider).mockResolvedValue(existing)
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ProviderForm', () => {
it('filters compact preset chips and resolves bundled logos', async () => {
const wrapper = await render()
await wrapper.get('#provider-search').setValue('通义')
expect(wrapper.find('[data-preset="deepseek"]').exists()).toBe(false)
expect(wrapper.find('[data-preset="qwen"]').exists()).toBe(true)
expect(wrapper.get('[data-preset="qwen"] img').attributes('src')).not.toMatch(/^https?:/)
})
it('clears the secret, old model and credential on preset and custom selection', async () => {
const wrapper = await render(existing)
await wrapper.get('input[type="password"]').setValue('draft-secret')
await wrapper.get('[data-preset="qwen"]').trigger('click')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
expect((wrapper.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
expect(wrapper.findAll('datalist option')).toHaveLength(0)
await wrapper.get('form').trigger('submit')
expect(service.updateProvider).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请输入 API Key')
await wrapper.get('input[type="password"]').setValue('new-secret')
wrapper.getComponent(ProviderPresetSelector).vm.$emit('update:modelValue', '')
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
})
it('allocates different credential IDs for two new providers using the same preset', async () => {
for (let i = 0; i < 2; i++) {
const wrapper = await render()
await wrapper.get('[data-preset="deepseek"]').trigger('click')
await wrapper.get('input[type="password"]').setValue(`test-key-${i}`)
await wrapper.get('form').trigger('submit')
await flushPromises()
}
const ids = vi.mocked(service.putCredential).mock.calls.map(call => call[0])
expect(ids).toHaveLength(2)
expect(new Set(ids).size).toBe(2)
ids.forEach(id => expect(id).toMatch(/^provider-key-[0-9a-f-]{36}$/))
vi.mocked(service.createProvider).mock.calls.forEach(([data], index) => {
expect(data.credential_id).toBe(ids[index])
expect(JSON.stringify(data)).not.toContain('test-key')
expect(JSON.stringify(data)).not.toContain('shared-deepseek')
})
})
it('accepts a custom API key and clears it after a failed credential save', async () => {
const wrapper = await render()
await wrapper.get('[data-field="name"]').setValue('Custom')
await wrapper.get('[data-field="base-url"]').setValue('https://custom.example.test/v1')
await wrapper.get('input[type="password"]').setValue('custom-test-key')
vi.mocked(service.putCredential).mockRejectedValueOnce(new Error('credential store unavailable'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).toContain('credential store unavailable')
expect(service.createProvider).not.toHaveBeenCalled()
await wrapper.get('input[type="password"]').setValue('custom-test-key')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.createProvider).toHaveBeenCalledWith(expect.objectContaining({ name: 'Custom', credential_id: expect.stringMatching(/^provider-key-/) }))
})
it('preserves its own existing key when untouched and rotates shared legacy references when replacing a key', async () => {
const untouched = await render(existing)
await untouched.get('form').trigger('submit')
await flushPromises()
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: 'old-shared-key', default_model: 'old-model' }))
const rotated = await render(existing)
await rotated.get('input[type="password"]').setValue('replacement-test-key')
await rotated.get('form').trigger('submit')
await flushPromises()
expect(service.putCredential).toHaveBeenCalledWith(expect.stringMatching(/^provider-key-/), 'replacement-test-key')
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: vi.mocked(service.putCredential).mock.calls[0][0] }))
})
it('persists edited protocols and unlinks the previous credential and model', async () => {
const wrapper = await render(existing)
await wrapper.get('[data-field="protocol"]').setValue('openai_responses')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({ provider_type: 'openai_responses', default_model: '', credential_id: null }))
})
it('does not let a late credential status reuse a key after switching presets', async () => {
let resolveStatus!: (configured: boolean) => void
vi.mocked(service.getCredentialStatus).mockReturnValue(new Promise(resolve => { resolveStatus = resolve }))
const wrapper = await render(existing)
await wrapper.get('[data-preset="qwen"]').trigger('click')
resolveStatus(true)
await flushPromises()
await wrapper.get('form').trigger('submit')
expect(service.updateProvider).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请输入 API Key')
})
it('clears secrets on close and unmount, and stops a pending credential save from creating a provider', async () => {
let finish!: () => void
vi.mocked(service.putCredential).mockReturnValue(new Promise(resolve => { finish = resolve }))
const wrapper = await render()
await wrapper.get('[data-preset="deepseek"]').trigger('click')
await wrapper.get('input[type="password"]').setValue('pending-test-key')
await wrapper.get('form').trigger('submit')
await wrapper.get('[aria-label="关闭提供商表单"]').trigger('click')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
wrapper.unmount()
finish()
await flushPromises()
expect(service.createProvider).not.toHaveBeenCalled()
const reopened = await render()
expect((reopened.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
})
it('clears a secret after provider save failure and keeps only the successfully saved reference for retry', async () => {
const wrapper = await render()
await wrapper.get('[data-preset="deepseek"]').trigger('click')
await wrapper.get('input[type="password"]').setValue('retry-test-key')
vi.mocked(service.createProvider).mockRejectedValueOnce(new Error('provider save failed'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('provider save failed')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.putCredential).toHaveBeenCalledTimes(1)
expect(service.createProvider).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType } from '@/contracts'
import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>()
const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>()
const newCredentialId = () => `provider-key-${crypto.randomUUID()}`
const form = reactive({
preset_id: '', provider_type: props.provider?.provider_type ?? 'openai_compatible' as ProviderType,
name: props.provider?.name ?? '', base_url: props.provider?.base_url ?? '',
default_model: props.provider?.default_model ?? '', enabled: props.provider?.enabled ?? true,
})
const credentialId = ref(props.provider?.credential_id || newCredentialId())
const apiKey = ref('')
const configured = ref(false)
const credentialLoading = ref(false)
const credentialError = ref('')
const presets = ref<ProviderPreset[]>([])
const presetsLoading = ref(false)
const presetsError = ref('')
const saving = ref(false)
const error = ref('')
const contextChanged = ref(false)
const dialog = ref<HTMLElement>()
const previousFocus = document.activeElement as HTMLElement | null
let active = true
let credentialGeneration = 0
const selectedPreset = computed(() => presets.value.find(preset => preset.preset_id === form.preset_id))
const modelOptions = computed(() => contextChanged.value ? [] : props.models ?? [])
async function loadPresets() {
presetsLoading.value = true
presetsError.value = ''
try {
presets.value = await service.listProviderPresets()
if (!contextChanged.value) form.preset_id = presets.value.find(preset => preset.provider_type === props.provider?.provider_type && preset.base_url === props.provider?.base_url)?.preset_id ?? ''
} catch { presetsError.value = '预设加载失败,请重试,或填写自定义服务。' }
finally { presetsLoading.value = false }
}
onMounted(async () => {
void loadPresets()
if (props.provider?.credential_id) {
const generation = credentialGeneration
credentialLoading.value = true
try {
const result = await service.getCredentialStatus(credentialId.value)
if (active && generation === credentialGeneration) configured.value = result
} catch {
if (active && generation === credentialGeneration) credentialError.value = '无法检查已保存的凭据。可输入新密钥,或关闭后重试。'
} finally {
if (generation === credentialGeneration) credentialLoading.value = false
}
}
await nextTick()
if (active) dialog.value?.querySelector<HTMLInputElement>('input')?.focus()
})
function detachCredential() {
credentialGeneration++
apiKey.value = ''
credentialId.value = newCredentialId()
configured.value = false
credentialLoading.value = false
credentialError.value = ''
form.default_model = ''
contextChanged.value = true
error.value = ''
}
function applyPreset(id: string) {
if (form.preset_id === id) return
detachCredential()
form.preset_id = id
const preset = presets.value.find(item => item.preset_id === id)
Object.assign(form, { provider_type: preset?.provider_type ?? 'openai_compatible', name: preset?.name ?? '', base_url: preset?.base_url ?? '' })
}
function changeConnection() {
form.preset_id = ''
detachCredential()
}
function close() {
active = false
apiKey.value = ''
emit('close')
}
onBeforeUnmount(() => {
active = false
apiKey.value = ''
previousFocus?.focus()
})
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); close() }
if (event.key !== 'Tab') return
const elements = Array.from(dialog.value?.querySelectorAll<HTMLElement>('button, input, select, [tabindex="0"]') ?? []).filter(element => !element.matches(':disabled'))
const first = elements[0], last = elements[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
}
async function save() {
if (saving.value || credentialLoading.value || !active) return
error.value = ''
saving.value = true
try {
if (!form.name.trim() || (form.provider_type !== 'mock' && !form.base_url.trim())) throw new Error('请填写名称和 Base URL。')
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false }
if (apiKey.value.trim()) {
// Rotate even an existing reference: older installations may share preset credential IDs.
const nextId = newCredentialId()
const request = service.putCredential(nextId, apiKey.value.trim())
apiKey.value = ''
await request
if (!active) return
credentialId.value = nextId
configured.value = true
}
const reference = configured.value ? credentialId.value : undefined
// A failed status check must not silently unlink the provider's existing credential.
if (credentialError.value && !reference) throw new Error(credentialError.value)
const saved = props.provider
? await service.updateProvider(props.provider.provider_id, { ...data, credential_id: reference ?? null })
: await service.createProvider({ ...data, credential_id: reference })
if (active) { emit('saved', saved); close() }
} catch (reason) {
if (active) error.value = reason instanceof Error ? reason.message : 'Provider 保存失败,请重试。'
} finally { apiKey.value = ''; saving.value = false }
}
</script>
<template>
<div class="modal-backdrop provider-backdrop" @click.self="close" @keydown="handleKeydown">
<div ref="dialog" class="modal provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-form-title" :aria-busy="saving">
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? '编辑 Provider' : '新增 Provider' }}</h2><button type="button" class="button-secondary" aria-label="关闭提供商表单" @click="close">关闭</button></div>
<p v-if="presetsLoading" class="subtle" role="status">正在加载提供商预设</p>
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">重试</button></div>
<form @submit.prevent="save">
<fieldset :disabled="saving">
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
<div class="form-grid">
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option><option v-if="provider?.provider_type === 'mock'" value="mock">Mock</option></select></label>
<label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label>
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" :required="form.provider_type !== 'mock'" @change="changeConnection" /></label>
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态</p>
<p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p>
<label class="field wide"><span>默认聊天模型</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" placeholder="输入模型 ID,或保存后获取模型列表" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
</div>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
</fieldset>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? '保存中…' : '保存提供商' }}</button><button type="button" class="button-secondary" @click="close">取消</button></div>
</form>
</div>
</div>
</template>
<style scoped>
.provider-modal { width: min(820px, 100%); max-height: 90dvh; }
.form-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); margin-bottom: var(--space-md); }
.form-heading h2 { margin: 0; }
fieldset { display: grid; gap: var(--space-md); border: 0; padding: 0; margin: 0; min-width: 0; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
.wide { grid-column: 1 / -1; }
.error-text { color: var(--color-error); }
.form-footer { padding-top: var(--space-sm); }
@media (max-width: 600px) { .provider-backdrop { padding: 12px; }.provider-modal { padding: var(--space-lg); max-height: 94dvh; }.form-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ logoId?: string }>()
const assets = import.meta.glob<string>('../../assets/providers/*.svg', { eager: true, query: '?url', import: 'default' })
const source = computed(() => {
const id = props.logoId === 'openai-responses' ? 'openai' : props.logoId
return assets[`../../assets/providers/${id}.svg`]
})
</script>
<template>
<span class="provider-logo" :class="{ 'dark-logo': logoId === 'kimi' }" aria-hidden="true">
<img v-if="source" :src="source" alt="" width="22" height="22" />
<span v-else class="custom-logo">+</span>
</span>
</template>
<style scoped>
.provider-logo { display: inline-flex; flex: 0 0 28px; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 7px; background: #fff; color: #252b36; }
img { display: block; object-fit: contain; }
.dark-logo { background: #111; }
.custom-logo { font-size: 23px; line-height: 1; }
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ProviderPreset } from '@/contracts'
import ProviderLogo from './ProviderLogo.vue'
const props = defineProps<{ presets: ProviderPreset[]; modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const search = ref('')
const filtered = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return props.presets.filter(preset => [preset.name, preset.preset_id, preset.description, preset.base_url]
.some(value => value?.toLocaleLowerCase().includes(query)))
})
</script>
<template>
<div class="preset-selector">
<label class="field" for="provider-search"><span>提供商预设</span><input id="provider-search" v-model="search" class="input" type="search" placeholder="搜索提供商,例如 通义千问 / DeepSeek" /></label>
<div class="preset-grid" role="group" aria-label="提供商预设">
<button type="button" class="preset-chip" :class="{ selected: !modelValue }" :aria-pressed="!modelValue" @click="emit('update:modelValue', '')"><ProviderLogo /><span>自定义</span></button>
<button v-for="preset in filtered" :key="preset.preset_id" type="button" class="preset-chip" :class="{ selected: modelValue === preset.preset_id }" :aria-pressed="modelValue === preset.preset_id" :title="preset.description || preset.name" :data-preset="preset.preset_id" @click="emit('update:modelValue', preset.preset_id)">
<ProviderLogo :logo-id="preset.logo_id || preset.preset_id" /><span>{{ preset.name }}</span>
</button>
</div>
<p v-if="search && !filtered.length" class="subtle" role="status">没有匹配的预设可以使用自定义服务</p>
</div>
</template>
<style scoped>
.preset-selector { display: grid; gap: var(--space-sm); }
.preset-grid { display: flex; flex-wrap: wrap; gap: 8px; max-height: 220px; overflow-y: auto; padding: 3px; }
.preset-chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid var(--color-border-default); border-radius: 11px; background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer; font: inherit; font-size: 13px; }
.preset-chip:hover { background: var(--color-background-hover); }
.preset-chip.selected { border-color: #377cf6; background: color-mix(in srgb, #377cf6 12%, var(--color-surface-primary)); color: #377cf6; box-shadow: 0 0 0 1px #377cf6; }
.preset-chip:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
</style>
+15 -85
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { ProviderConfig, ProviderType } from '@/contracts'
import { onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue'
import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
@@ -15,14 +18,9 @@ const settingsStore = useSettingsStore()
const providerStore = useProviderStore()
const themeStore = useThemeStore()
const showProviderForm = ref(false)
const editingProviderId = ref<string | null>(null)
const editingProvider = ref<ProviderConfig>()
const providerAction = ref('')
const testResults = ref<Record<string, string>>({})
const providerApiKey = ref('')
const providerForm = reactive({ preset_id: '', provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
const formModels = computed(() => editingProviderId.value ? providerStore.modelsByProvider[editingProviderId.value] ?? [] : [])
const selectedPreset = computed(() => providerStore.presets.find((item) => item.preset_id === providerForm.preset_id) ?? null)
onMounted(async () => {
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
await providerStore.refreshEnabledModels()
@@ -36,54 +34,15 @@ function presetIdFor(provider?: ProviderConfig) {
}
function openProvider(provider?: ProviderConfig) {
editingProviderId.value = provider?.provider_id ?? null
const presetId = presetIdFor(provider)
const preset = providerStore.presets.find((item) => item.preset_id === presetId)
Object.assign(providerForm, { preset_id: presetId, provider_type: provider?.provider_type ?? 'openai_compatible', name: provider?.name ?? '', base_url: provider?.base_url ?? '', default_model: provider?.default_model ?? '', credential_id: provider?.credential_id ?? preset?.default_credential_id ?? '', enabled: provider?.enabled ?? true })
providerApiKey.value = ''
editingProvider.value = provider
providerAction.value = ''
showProviderForm.value = true
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
}
function applyProviderPreset() {
const preset = providerStore.presets.find((item) => item.preset_id === providerForm.preset_id)
if (!preset) return
Object.assign(providerForm, {
provider_type: preset.provider_type,
name: preset.name,
base_url: preset.base_url,
credential_id: preset.default_credential_id ?? '',
})
providerApiKey.value = ''
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
}
function closeProvider() {
providerApiKey.value = ''
showProviderForm.value = false
}
async function saveProvider() {
providerAction.value = ''
const credentialId = providerForm.credential_id.trim()
const requiresApiKey = Boolean(selectedPreset.value?.requires_credential)
if (requiresApiKey && !providerApiKey.value && !providerStore.credentialConfiguredById[credentialId]) {
providerAction.value = '请输入 API Key。密钥将由后端加密保存。'
return
}
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
try {
if (providerApiKey.value) await providerStore.saveCredential(credentialId, providerApiKey.value)
const saved = editingProviderId.value
? await providerStore.updateProvider(editingProviderId.value, data)
: await providerStore.addProvider(data)
closeProvider()
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
} catch (error) {
providerApiKey.value = ''
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
}
async function providerSaved(provider: ProviderConfig) {
await providerStore.loadProviders()
if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
}
async function removeProvider(provider: ProviderConfig) { if (!confirm(`确定删除 Provider“${provider.name}”吗?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : '删除失败' } }
@@ -107,14 +66,14 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<div v-else-if="activeSection === 'providers'" class="settings-section">
<div class="section-head">
<div><h2>模型提供商</h2><p class="subtle">支持 OpenAIDeepSeekOllama 和自定义兼容服务</p></div>
<div><h2>模型提供商</h2><p class="subtle">选择国内外提供商预设或配置自定义 API 与独立密钥</p></div>
<button class="button-primary" @click="openProvider()">新增 Provider</button>
</div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
<div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
<div class="provider-main">
<div class="inline-actions"><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p>
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
@@ -138,42 +97,13 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
</div>
</div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div></div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">高影响能力默认需要确认。未知权限由后端拒绝。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><select :value="policy" class="select short" @change="settingsStore.setPermission(String(permission), ($event.target as HTMLSelectElement).value as 'allow' | 'confirm' | 'deny')"><option value="allow">允许</option><option value="confirm">每次确认</option><option value="deny">拒绝</option></select></div></div></div>
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div>
<div v-if="showProviderForm" class="modal-backdrop" @click.self="closeProvider">
<div class="modal">
<h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2>
<form @submit.prevent="saveProvider">
<div class="field">
<label>提供商预设</label>
<select v-model="providerForm.preset_id" class="select" @change="applyProviderPreset">
<option value="">自定义</option>
<option v-for="preset in providerStore.presets" :key="preset.preset_id" :value="preset.preset_id">{{ preset.name }}</option>
</select>
</div>
<div class="field"><label>接入协议</label><select v-model="providerForm.provider_type" class="select"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></div>
<div class="field"><label>名称</label><input v-model="providerForm.name" class="input" required /></div>
<div class="field"><label>Base URL</label><input v-model="providerForm.base_url" class="input" placeholder="https://api.example.com/v1" required /></div>
<div class="field">
<label>默认模型</label>
<input v-model="providerForm.default_model" class="input" :list="editingProviderId ? 'provider-model-options' : undefined" placeholder="保存后自动获取,也可以手动输入" />
<datalist id="provider-model-options"><option v-for="model in formModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist>
</div>
<div v-if="selectedPreset?.requires_credential" class="field">
<label>API Key</label>
<input v-model="providerApiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="providerStore.credentialConfiguredById[providerForm.credential_id] ? '已配置,留空表示不修改' : '请输入 API Key'" />
<small class="subtle">提交后由本地 AI Core 加密保存页面不会回显已保存的密钥</small>
</div>
<div v-else-if="!selectedPreset" class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="自定义凭据标识" /><small class="subtle">自定义服务可以引用 Host 注入或后端已保存的凭据</small></div>
<label class="inline-actions"><input v-model="providerForm.enabled" type="checkbox" /> 启用</label>
<div class="inline-actions"><button class="button-primary">保存并获取模型</button><button type="button" class="button-secondary" @click="closeProvider">取消</button></div>
</form>
</div>
</div>
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
</section>
</template>