feat(frontend): 支持直接配置模型API密钥
This commit is contained in:
@@ -18,8 +18,10 @@ 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 providerApiKey = ref('')
|
||||||
const providerForm = reactive({ preset_id: '', 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] ?? [] : [])
|
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 () => {
|
onMounted(async () => {
|
||||||
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
|
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
|
||||||
@@ -38,7 +40,9 @@ function openProvider(provider?: ProviderConfig) {
|
|||||||
const presetId = presetIdFor(provider)
|
const presetId = presetIdFor(provider)
|
||||||
const preset = providerStore.presets.find((item) => item.preset_id === presetId)
|
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 })
|
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 = ''
|
||||||
showProviderForm.value = true
|
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)
|
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,18 +55,33 @@ function applyProviderPreset() {
|
|||||||
base_url: preset.base_url,
|
base_url: preset.base_url,
|
||||||
credential_id: preset.default_credential_id ?? '',
|
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() {
|
async function saveProvider() {
|
||||||
providerAction.value = ''
|
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) }
|
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
|
||||||
try {
|
try {
|
||||||
|
if (providerApiKey.value) await providerStore.saveCredential(credentialId, providerApiKey.value)
|
||||||
const saved = editingProviderId.value
|
const saved = editingProviderId.value
|
||||||
? await providerStore.updateProvider(editingProviderId.value, data)
|
? await providerStore.updateProvider(editingProviderId.value, data)
|
||||||
: await providerStore.addProvider(data)
|
: await providerStore.addProvider(data)
|
||||||
showProviderForm.value = false
|
closeProvider()
|
||||||
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
|
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
providerApiKey.value = ''
|
||||||
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
|
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +144,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
|||||||
|
|
||||||
<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 v-if="showProviderForm" class="modal-backdrop" @click.self="closeProvider">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2>
|
<h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2>
|
||||||
<form @submit.prevent="saveProvider">
|
<form @submit.prevent="saveProvider">
|
||||||
@@ -144,9 +163,14 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
|||||||
<input v-model="providerForm.default_model" class="input" :list="editingProviderId ? 'provider-model-options' : undefined" placeholder="保存后自动获取,也可以手动输入" />
|
<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>
|
<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>
|
||||||
<div class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="例如 deepseek" /><small class="subtle">这里填写凭据标识,不是 API Key。DeepSeek 开发环境默认读取 DEEPSEEK_API_KEY,密钥明文不会保存到 Provider。</small></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>
|
<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>
|
<div class="inline-actions"><button class="button-primary">保存并获取模型</button><button type="button" class="button-secondary" @click="closeProvider">取消</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ export async function listProviderPresets(): Promise<ProviderPreset[]> {
|
|||||||
return response.items
|
return response.items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCredentialStatus(credentialId: string): Promise<boolean> {
|
||||||
|
const response = await apiClient.get<{ credential_id: string; configured: boolean }>(`/api/credentials/${encodeURIComponent(credentialId)}`)
|
||||||
|
return response.configured
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putCredential(credentialId: string, apiKey: string): Promise<void> {
|
||||||
|
await apiClient.put<{ credential_id: string; configured: boolean }>(
|
||||||
|
`/api/credentials/${encodeURIComponent(credentialId)}`,
|
||||||
|
{ api_key: apiKey },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ vi.mock('@/services/providerService', () => ({
|
|||||||
mockModels: {},
|
mockModels: {},
|
||||||
listProviders: vi.fn(),
|
listProviders: vi.fn(),
|
||||||
listProviderPresets: vi.fn(),
|
listProviderPresets: vi.fn(),
|
||||||
|
getCredentialStatus: vi.fn(),
|
||||||
|
putCredential: vi.fn(),
|
||||||
listModels: vi.fn(),
|
listModels: vi.fn(),
|
||||||
createProvider: vi.fn(),
|
createProvider: vi.fn(),
|
||||||
updateProvider: vi.fn(),
|
updateProvider: vi.fn(),
|
||||||
@@ -16,7 +18,7 @@ vi.mock('@/services/providerService', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { useProviderStore } from './provider'
|
import { useProviderStore } from './provider'
|
||||||
import { listModels, listProviderPresets, listProviders } from '@/services/providerService'
|
import { getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential } from '@/services/providerService'
|
||||||
import { ApiErrorClass } from '@/services/apiClient'
|
import { ApiErrorClass } from '@/services/apiClient'
|
||||||
|
|
||||||
const providers: ProviderConfig[] = [
|
const providers: ProviderConfig[] = [
|
||||||
@@ -49,6 +51,8 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(listProviders).mockResolvedValue(providers)
|
vi.mocked(listProviders).mockResolvedValue(providers)
|
||||||
vi.mocked(listProviderPresets).mockResolvedValue(presets)
|
vi.mocked(listProviderPresets).mockResolvedValue(presets)
|
||||||
|
vi.mocked(getCredentialStatus).mockResolvedValue(false)
|
||||||
|
vi.mocked(putCredential).mockResolvedValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('provider store model discovery', () => {
|
describe('provider store model discovery', () => {
|
||||||
@@ -89,6 +93,16 @@ describe('provider store model discovery', () => {
|
|||||||
|
|
||||||
await expect(store.loadModels('openai')).rejects.toThrow('Credential is unavailable')
|
await expect(store.loadModels('openai')).rejects.toThrow('Credential is unavailable')
|
||||||
|
|
||||||
expect(store.modelErrorsByProvider.openai).toContain('DEEPSEEK_API_KEY')
|
expect(store.modelErrorsByProvider.openai).toContain('填写并保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends an API key to the credential endpoint without storing it in Pinia', async () => {
|
||||||
|
const store = useProviderStore()
|
||||||
|
|
||||||
|
await store.saveCredential('deepseek', 'sk-test-sensitive-value')
|
||||||
|
|
||||||
|
expect(putCredential).toHaveBeenCalledWith('deepseek', 'sk-test-sensitive-value')
|
||||||
|
expect(store.credentialConfiguredById.deepseek).toBe(true)
|
||||||
|
expect(JSON.stringify(store.$state)).not.toContain('sk-test-sensitive-value')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
|
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
|
||||||
import { createProvider, deleteProvider as deleteProviderRequest, listModels, listProviderPresets, listProviders, mockProviders, mockModels, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
|
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, mockProviders, mockModels, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
|
||||||
import { ApiErrorClass } from '@/services/apiClient'
|
import { ApiErrorClass } from '@/services/apiClient'
|
||||||
|
|
||||||
export const useProviderStore = defineStore('provider', () => {
|
export const useProviderStore = defineStore('provider', () => {
|
||||||
@@ -10,6 +10,7 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
|
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
|
||||||
const modelLoadingByProvider = ref<Record<string, boolean>>({})
|
const modelLoadingByProvider = ref<Record<string, boolean>>({})
|
||||||
const modelErrorsByProvider = ref<Record<string, string>>({})
|
const modelErrorsByProvider = ref<Record<string, string>>({})
|
||||||
|
const credentialConfiguredById = ref<Record<string, boolean>>({})
|
||||||
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)
|
||||||
@@ -53,10 +54,7 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
const credentialId = provider?.credential_id
|
const credentialId = provider?.credential_id
|
||||||
let message = reason instanceof Error ? reason.message : '模型列表获取失败'
|
let message = reason instanceof Error ? reason.message : '模型列表获取失败'
|
||||||
if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') {
|
if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') {
|
||||||
const environmentName = credentialId === 'deepseek'
|
message = '尚未配置 API Key,请编辑该 Provider 后填写并保存。'
|
||||||
? 'DEEPSEEK_API_KEY'
|
|
||||||
: `AINOTE_CREDENTIAL_${credentialId?.replace(/[^A-Za-z0-9]/g, '_').toUpperCase() || '<ID>'}`
|
|
||||||
message = `未找到凭据“${credentialId || '未设置'}”,请在启动 AI Core 前设置 ${environmentName}。`
|
|
||||||
} else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') {
|
} else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') {
|
||||||
message = `鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`
|
message = `鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`
|
||||||
}
|
}
|
||||||
@@ -73,6 +71,17 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCredentialStatus(credentialId: string): Promise<boolean> {
|
||||||
|
const configured = await getCredentialStatus(credentialId)
|
||||||
|
credentialConfiguredById.value[credentialId] = configured
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCredential(credentialId: string, apiKey: string) {
|
||||||
|
await putCredential(credentialId, apiKey)
|
||||||
|
credentialConfiguredById.value[credentialId] = true
|
||||||
|
}
|
||||||
|
|
||||||
async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) {
|
async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) {
|
||||||
const newProvider = await createProvider(data)
|
const newProvider = await createProvider(data)
|
||||||
providers.value.push(newProvider)
|
providers.value.push(newProvider)
|
||||||
@@ -108,6 +117,7 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
modelsByProvider,
|
modelsByProvider,
|
||||||
modelLoadingByProvider,
|
modelLoadingByProvider,
|
||||||
modelErrorsByProvider,
|
modelErrorsByProvider,
|
||||||
|
credentialConfiguredById,
|
||||||
defaultProviderId,
|
defaultProviderId,
|
||||||
enabledProviders,
|
enabledProviders,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
@@ -117,6 +127,8 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
loadPresets,
|
loadPresets,
|
||||||
loadModels,
|
loadModels,
|
||||||
refreshEnabledModels,
|
refreshEnabledModels,
|
||||||
|
loadCredentialStatus,
|
||||||
|
saveCredential,
|
||||||
addProvider,
|
addProvider,
|
||||||
updateProvider,
|
updateProvider,
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
|
|||||||
Reference in New Issue
Block a user