Fix/frontend review findings #4

Merged
Kronecker merged 23 commits from fix/frontend-review-findings into main 2026-08-30 15:06:05 +08:00
5 changed files with 243 additions and 14 deletions
Showing only changes of commit 0f6938c6a2 - Show all commits
+16
View File
@@ -290,6 +290,14 @@ export interface ProviderConfig {
has_credential: boolean has_credential: boolean
} }
export interface ProviderPreset {
preset_id: string
name: string
provider_type: ProviderType
base_url: string
requires_credential: boolean
}
// ============ Tasks ============ // ============ Tasks ============
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled' export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
@@ -510,6 +518,14 @@ export interface ApiProviderConfig {
capabilities: string[] capabilities: string[]
} }
export interface ApiProviderPreset {
preset_id: string
name: string
provider_type: ApiProviderType
base_url: string
requires_credential: boolean
}
export interface ApiModelInfo { export interface ApiModelInfo {
model: string model: string
display_name: string display_name: string
+101 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import type { ProviderConfig, ProviderType } from '@/contracts' import type { ProviderConfig, ProviderType } from '@/contracts'
import { useProviderStore } from '@/stores/provider' import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
@@ -18,24 +18,60 @@ const showProviderForm = ref(false)
const editingProviderId = ref<string | null>(null) const editingProviderId = ref<string | null>(null)
const providerAction = ref('') const providerAction = ref('')
const testResults = ref<Record<string, string>>({}) const testResults = ref<Record<string, string>>({})
const providerForm = reactive({ provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true }) 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] ?? [] : [])
onMounted(() => { void providerStore.loadProviders(); void settingsStore.loadDiagnostics() }) onMounted(async () => {
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
await providerStore.refreshEnabledModels()
})
function presetIdFor(provider?: ProviderConfig) {
if (!provider) return ''
return providerStore.presets.find((preset) =>
preset.provider_type === provider.provider_type && preset.base_url === provider.base_url
)?.preset_id ?? ''
}
function openProvider(provider?: ProviderConfig) { function openProvider(provider?: ProviderConfig) {
editingProviderId.value = provider?.provider_id ?? null editingProviderId.value = provider?.provider_id ?? null
Object.assign(providerForm, { 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 ?? '', enabled: provider?.enabled ?? true }) Object.assign(providerForm, { preset_id: presetIdFor(provider), 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 ?? '', enabled: provider?.enabled ?? true })
showProviderForm.value = true showProviderForm.value = true
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,
})
} }
async function saveProvider() { async function saveProvider() {
providerAction.value = '' providerAction.value = ''
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) } const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
try { if (editingProviderId.value) await providerStore.updateProvider(editingProviderId.value, data); else await providerStore.addProvider(data); showProviderForm.value = false } catch (error) { providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败' } try {
const saved = editingProviderId.value
? await providerStore.updateProvider(editingProviderId.value, data)
: await providerStore.addProvider(data)
showProviderForm.value = false
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
} catch (error) {
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
}
} }
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 : '删除失败' } } 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 : '删除失败' } }
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = '测试中…'; const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `连接成功${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `连接失败:${result.error}` } async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = '测试中…'; const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `连接成功${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `连接失败:${result.error}` }
async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
const defaultModel = (event.target as HTMLSelectElement).value
try { await providerStore.updateProvider(provider.provider_id, { default_model: defaultModel }) }
catch (error) { providerAction.value = error instanceof Error ? error.message : '默认模型更新失败' }
}
</script> </script>
<template> <template>
@@ -47,7 +83,38 @@ async function testProvider(provider: ProviderConfig) { testResults.value[provid
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>编辑器</h2><div class="setting-row"><span><strong>默认模式</strong><small>新打开文件使用的编辑器模式</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">写作与预览</option><option value="source">Markdown 源码</option></select></div><div class="setting-row"><span><strong>字号</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>行高</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>行宽</strong><small>Markdown 预览最大字符宽度</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>拼写检查</strong></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div> <div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>编辑器</h2><div class="setting-row"><span><strong>默认模式</strong><small>新打开文件使用的编辑器模式</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">写作与预览</option><option value="source">Markdown 源码</option></select></div><div class="setting-row"><span><strong>字号</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>行高</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>行宽</strong><small>Markdown 预览最大字符宽度</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>拼写检查</strong></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
<div v-else-if="activeSection === 'providers'" class="settings-section"><div class="section-head"><h2>模型提供商</h2><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><div class="inline-actions"><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><p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p></div><div class="inline-actions"><button class="button-secondary" @click="testProvider(provider)">测试</button><button class="button-secondary" @click="openProvider(provider)">编辑</button><button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button></div></article></div></div> <div v-else-if="activeSection === 'providers'" class="settings-section">
<div class="section-head">
<div><h2>模型提供商</h2><p class="subtle">支持 OpenAIDeepSeekOllama 和自定义兼容服务</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>
<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">
<label :for="`default-model-${provider.provider_id}`">默认模型</label>
<select :id="`default-model-${provider.provider_id}`" class="select" :value="provider.default_model" @change="chooseDefaultModel(provider, $event)">
<option value="">未设置</option>
<option v-for="model in providerStore.modelsByProvider[provider.provider_id]" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
</select>
<span class="subtle">已获取 {{ providerStore.modelsByProvider[provider.provider_id].length }} 个模型</span>
</div>
<p v-if="providerStore.modelErrorsByProvider[provider.provider_id]" class="error-text">模型获取失败{{ providerStore.modelErrorsByProvider[provider.provider_id] }}</p>
<p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
</div>
<div class="inline-actions provider-actions">
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? '获取中' : '刷新模型' }}</button>
<button class="button-secondary" @click="testProvider(provider)">测试</button>
<button class="button-secondary" @click="openProvider(provider)">编辑</button>
<button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button>
</div>
</article>
</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></div>
@@ -55,7 +122,31 @@ async function testProvider(provider: ProviderConfig) { testResults.value[provid
<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-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="showProviderForm = false"><div class="modal"><h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2><form @submit.prevent="saveProvider"><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" /></div><div class="field"><label>默认模型</label><input v-model="providerForm.default_model" class="input" /></div><div class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="密钥明文由 Stronghold 保存" /><small class="subtle">此处不输入或回显 API Key。</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="showProviderForm = false">取消</button></div></form></div></div> <div v-if="showProviderForm" class="modal-backdrop" @click.self="showProviderForm = false">
<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 class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="由桌面 Host 注入的凭据标识" /><small class="subtle">此处不输入或回显 API Key密钥明文由 Stronghold 保存</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="showProviderForm = false">取消</button></div>
</form>
</div>
</div>
</section> </section>
</template> </template>
@@ -66,8 +157,9 @@ async function testProvider(provider: ProviderConfig) { testResults.value[provid
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 54px; padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border-subtle); } .setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 54px; padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border-subtle); }
.setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); } .setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); }
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); } .section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); }
.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); } .provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-main { min-width: 0; flex: 1; }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
.model-picker { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md); }.model-picker label { white-space: nowrap; font-weight: 600; }.model-picker .select { width: min(360px, 100%); }.provider-actions { flex-wrap: wrap; justify-content: flex-end; }.error-text { color: var(--color-danger, #d33); }
.test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); } .test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); }
.section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); } .section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); }
@media (max-width: 700px) { .provider-card, .setting-row { align-items: flex-start; flex-direction: column; }.short { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; } } @media (max-width: 700px) { .provider-card, .setting-row, .model-picker { align-items: flex-start; flex-direction: column; }.short, .model-picker .select { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; }.provider-actions { justify-content: flex-start; } }
</style> </style>
+6 -1
View File
@@ -1,5 +1,5 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { ApiModelInfo, ApiProviderConfig, ModelCapability, ModelInfo, OperationResponse, ProviderConfig } from '@/contracts' import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts'
function capabilityMap(capabilities: string[]): Partial<ModelCapability> { function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability> return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
@@ -44,6 +44,11 @@ export async function createProvider(data: Omit<ProviderConfig, 'provider_id'>):
return toProvider(response) return toProvider(response)
} }
export async function listProviderPresets(): Promise<ProviderPreset[]> {
const response = await apiClient.get<{ items: ApiProviderPreset[] }>('/api/providers/presets')
return response.items
}
export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> { export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, { const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
name: data.name, name: data.name,
+79
View File
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { ProviderConfig, ProviderPreset } from '@/contracts'
vi.mock('@/services/providerService', () => ({
mockProviders: [],
mockModels: {},
listProviders: vi.fn(),
listProviderPresets: vi.fn(),
listModels: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
testProvider: vi.fn(),
}))
import { useProviderStore } from './provider'
import { listModels, listProviderPresets, listProviders } from '@/services/providerService'
const providers: ProviderConfig[] = [
{
provider_id: 'openai',
provider_type: 'openai_chat',
name: 'OpenAI',
base_url: 'https://api.openai.com/v1',
default_model: '',
enabled: true,
capabilities: { chat: true },
has_credential: true,
},
]
const presets: ProviderPreset[] = [
{
preset_id: 'deepseek',
name: 'DeepSeek',
provider_type: 'openai_compatible',
base_url: 'https://api.deepseek.com',
requires_credential: true,
},
]
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(listProviders).mockResolvedValue(providers)
vi.mocked(listProviderPresets).mockResolvedValue(presets)
})
describe('provider store model discovery', () => {
it('loads provider presets and automatically refreshes enabled providers', async () => {
vi.mocked(listModels).mockResolvedValue([
{ model_id: 'model-z', name: 'Zulu', capabilities: { chat: true } },
{ model_id: 'model-a', name: 'Alpha', capabilities: { chat: true } },
{ model_id: 'model-a', name: 'Alpha duplicate', capabilities: { chat: true } },
])
const store = useProviderStore()
await store.loadProviders()
await store.loadPresets()
await store.refreshEnabledModels()
expect(store.presets[0].preset_id).toBe('deepseek')
expect(listModels).toHaveBeenCalledWith('openai')
expect(store.modelsByProvider.openai.map((model) => model.model_id)).toEqual(['model-a', 'model-z'])
expect(store.modelErrorsByProvider.openai).toBeUndefined()
})
it('records a provider-specific error when model discovery fails', async () => {
vi.mocked(listModels).mockRejectedValue(new Error('认证失败'))
const store = useProviderStore()
await expect(store.loadModels('openai')).rejects.toThrow('认证失败')
expect(store.modelLoadingByProvider.openai).toBe(false)
expect(store.modelErrorsByProvider.openai).toBe('认证失败')
})
})
+41 -4
View File
@@ -1,11 +1,14 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo } from '@/contracts' import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
import { createProvider, deleteProvider as deleteProviderRequest, listModels, listProviders, mockProviders, mockModels, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService' import { createProvider, deleteProvider as deleteProviderRequest, listModels, listProviderPresets, listProviders, mockProviders, mockModels, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
export const useProviderStore = defineStore('provider', () => { export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>(mockProviders) const providers = ref<ProviderConfig[]>(mockProviders)
const presets = ref<ProviderPreset[]>([])
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels) const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
const modelLoadingByProvider = ref<Record<string, boolean>>({})
const modelErrorsByProvider = ref<Record<string, string>>({})
const defaultProviderId = ref('mock') const defaultProviderId = ref('mock')
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -27,8 +30,36 @@ export const useProviderStore = defineStore('provider', () => {
} }
} }
async function loadModels(providerId: string) { async function loadPresets() {
modelsByProvider.value[providerId] = await listModels(providerId) try {
presets.value = await listProviderPresets()
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 预设加载失败'
}
}
async function loadModels(providerId: string): Promise<ModelInfo[]> {
modelLoadingByProvider.value[providerId] = true
delete modelErrorsByProvider.value[providerId]
try {
const models = await listModels(providerId)
const uniqueModels = [...new Map(models.map((model) => [model.model_id, model])).values()]
.sort((left, right) => left.name.localeCompare(right.name))
modelsByProvider.value[providerId] = uniqueModels
return uniqueModels
} catch (reason) {
const message = reason instanceof Error ? reason.message : '模型列表获取失败'
modelErrorsByProvider.value[providerId] = message
throw reason
} finally {
modelLoadingByProvider.value[providerId] = false
}
}
async function refreshEnabledModels() {
await Promise.allSettled(
providers.value.filter((provider) => provider.enabled).map((provider) => loadModels(provider.provider_id))
)
} }
async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) { async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) {
@@ -41,6 +72,7 @@ export const useProviderStore = defineStore('provider', () => {
const updated = await updateProviderRequest(providerId, data) const updated = await updateProviderRequest(providerId, data)
const index = providers.value.findIndex((provider) => provider.provider_id === providerId) const index = providers.value.findIndex((provider) => provider.provider_id === providerId)
if (index >= 0) providers.value[index] = updated if (index >= 0) providers.value[index] = updated
return updated
} }
async function deleteProvider(providerId: string) { async function deleteProvider(providerId: string) {
@@ -61,14 +93,19 @@ export const useProviderStore = defineStore('provider', () => {
return { return {
providers, providers,
presets,
modelsByProvider, modelsByProvider,
modelLoadingByProvider,
modelErrorsByProvider,
defaultProviderId, defaultProviderId,
enabledProviders, enabledProviders,
defaultProvider, defaultProvider,
isLoading, isLoading,
error, error,
loadProviders, loadProviders,
loadPresets,
loadModels, loadModels,
refreshEnabledModels,
addProvider, addProvider,
updateProvider, updateProvider,
deleteProvider, deleteProvider,