Files
DevFlow/src/components/settings/ProviderPanel.vue
T
lxy 7aa0da32f2 修复: 协议层与体验缺陷六件
- temperature 按模型适配,仅收 1 的模型不再 400
- 流式 error 帧不再被 choices 必填整帧丢弃
- 只读缓存写后失效
- git diff 中文截断撞 UTF-8 边界 panic
- 非视觉模型纯图片直接拒绝
- 全局快捷键补输入焦点守卫
2026-10-04 16:27:31 +08:00

962 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- ═══ AI 模型配置(provider 列表)═══ -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelAiProvider') }}</h2>
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
</div>
<div class="provider-list" v-if="aiProviders.length > 0">
<div class="provider-card" :class="{ 'provider-card--default': p.is_default, 'provider-card--disabled': p.enabled === false }" v-for="p in aiProviders" :key="p.id">
<div class="provider-top">
<div class="provider-info">
<span class="provider-name">{{ p.name }}</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
<span v-if="p.enabled === false" class="pool-badge">{{ $t('settings.badgePoolDisabled') }}</span>
</div>
<div class="provider-actions">
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
<button class="btn-link" @click="openProviderForm(p)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">{{ $t('common.delete') }}</button>
</div>
</div>
<div class="provider-detail">
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
<span class="detail-value">{{ p.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
<span class="detail-value">{{ p.default_model }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div>
<!-- F-260614-04c 负载均衡池:enabled toggle + weight 即时调 IPC,落库即重建 caps -->
<div class="detail-row detail-row--pool">
<span class="detail-label" :title="$t('settings.descPoolEnabled')">{{ $t('settings.detailPoolEnabled') }}</span>
<div class="detail-value detail-value--control">
<label class="toggle toggle--sm" :title="$t('settings.descPoolEnabled')">
<input type="checkbox" :checked="p.enabled !== false" @change="onPoolToggle(p, ($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="detail-row detail-row--pool" v-if="p.enabled !== false">
<span class="detail-label" :title="$t('settings.descPoolWeight')">{{ $t('settings.detailPoolWeight') }}</span>
<div class="detail-value detail-value--control">
<input type="number" class="setting-input setting-input--sm" min="0" max="100"
:value="p.weight ?? 50"
@change="onPoolWeightChange(p, ($event.target as HTMLInputElement).valueAsNumber)" />
<span class="pool-weight-hint">0-100</span>
</div>
</div>
</div>
</div>
</div>
<div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div>
</section>
<!-- ═══ AI 提供商表单 ═══ -->
<section class="panel" v-if="providerForm.visible">
<div class="panel-header">
<h2>{{ providerForm.editId ? $t('settings.editProviderTitle') : $t('settings.addProviderTitle') }}</h2>
<!-- L19: 取消走 closeProviderForm,脏表单二次确认后再关(防误弃改动) -->
<button class="btn btn-ghost btn-sm" @click="closeProviderForm">{{ $t('common.cancel') }}</button>
</div>
<div class="form-grid">
<div class="form-field">
<label class="form-label">{{ $t('settings.labelName') }}</label>
<!-- 走查 P3-10:校验失败定位字段——红框 + @input 清错(示范级,仅本表单) -->
<input ref="inputName" v-model="providerForm.name" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.name }"
:placeholder="$t('settings.phProviderName')" @input="fieldErrors.name = false" />
</div>
<!-- 项 D:厂商预设下拉 — 选中自动填充 providerType/baseUrl/defaultModel(用户可改覆盖)。
新建态独立"快捷选择";编辑态回填后切预设仍可覆盖(等价快捷填充),不阻塞手改。
"自定义" 不预填任何字段。 -->
<div class="form-field">
<label class="form-label">{{ $t('settings.labelPreset') }}</label>
<select v-model="providerForm.presetId" class="setting-select" @change="onPresetChange">
<option value="__custom">{{ $t('settings.presetCustom') }}</option>
<option v-for="p in PROVIDER_PRESETS" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelProviderType') }}</label>
<select v-model="providerForm.providerType" class="setting-select">
<option value="openai_compat">{{ $t('settings.providerTypeOpenaiCompat') }}</option>
<option value="anthropic">{{ $t('settings.providerTypeAnthropic') }}</option>
</select>
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelBaseUrl') }}</label>
<input ref="inputBaseUrl" v-model="providerForm.baseUrl" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.baseUrl }"
:placeholder="$t('settings.phBaseUrl')" @input="fieldErrors.baseUrl = false" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
<!-- 走查 P3-9:type 恒 password 无法核对已粘贴密钥,加显隐切换 -->
<input ref="inputApiKey" v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'"
:class="{ 'setting-input--invalid': fieldErrors.apiKey }"
:placeholder="$t('settings.phApiKey')" @input="fieldErrors.apiKey = false" />
<button type="button" class="btn-link" :title="$t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow')"
@click="showApiKey = !showApiKey">
{{ $t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow') }}
</button>
<!-- 走查 P3-9:编辑态留空=沿用已存密钥(后端 FR-S1),此前表单零说明,用户误以为必须重输。
仅编辑态显(新建态密钥必填,不存在"留空"语义)。 -->
<div v-if="providerForm.editId" class="form-field-hint">{{ $t('settings.apiKeyKeepHint') }}</div>
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
<input ref="inputDefaultModel" v-model="providerForm.defaultModel" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.defaultModel }"
:placeholder="$t('settings.phDefaultModel')" @input="fieldErrors.defaultModel = false" />
<!-- 方向 A(轻量):defaultModel 仅作"建议起点"——预设 model id 易过时(DeepSeek chat 停用 /
Gemini 2.5→3.5 等),提示用户点「测试连接」拉真实模型列表覆盖,降低踩过时默认值的困惑。 -->
<div class="form-field-hint">{{ $t('settings.defaultModelHint') }}</div>
</div>
<!-- 供应商级采样温度覆盖:部分受限模型(如 Kimi 系)仅接受 temperature=1,留空走默认 0.7 -->
<div class="form-field">
<label class="form-label">{{ $t('settings.labelSamplingTemp') }}</label>
<!-- 走查 P3-14:原生 min/max 只约束步进按钮,手输越界由 saveProvider clamp + toast 提示 -->
<input type="number" v-model.number="providerForm.temperature" class="setting-input"
:placeholder="$t('settings.phSamplingTemp')" step="0.1" min="0" max="2" />
</div>
<!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)。
含分类建议:keyring/迁移失败 → 查系统钥匙串权限;其它 → 通用重试。
用户看到错误同时表单保持编辑态,改完点保存即可重试。 -->
<div v-if="providerForm.saveError" class="save-error-banner">
<pre class="save-error-msg">{{ providerForm.saveError }}</pre>
</div>
<div class="form-actions">
<button class="btn btn-primary" @click="saveProvider" :disabled="providerForm.saving">
{{ providerForm.saving ? $t('settings.saving') : $t('common.save') }}
</button>
<button class="btn btn-ghost" @click="fetchModels" :disabled="providerForm.fetching || !canTestConnection">
{{ providerForm.fetching ? $t('settings.fetchingModels') : $t('settings.testConnection') }}
</button>
</div>
<!-- ═══ 拉取到的模型列表(F-01 阶段6)═══ -->
<div class="form-field form-field--full" v-if="providerForm.models.length > 0">
<label class="form-label">{{ $t('settings.modelListTitle', { count: providerForm.models.length }) }}</label>
<table class="model-table">
<thead>
<tr>
<th class="col-enabled">{{ $t('settings.colEnabled') }}</th>
<th class="col-model">{{ $t('settings.colModel') }}</th>
<th class="col-ability">{{ $t('settings.colAbility') }}</th>
<th class="col-weight">{{ $t('settings.labelWeight') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in providerForm.models" :key="m.model_id">
<td class="col-enabled">
<label class="toggle toggle--sm">
<input type="checkbox" v-model="m.enabled" />
<span class="toggle-slider"></span>
</label>
</td>
<td class="col-model">
<span class="model-id">{{ m.model_id }}</span>
</td>
<td class="col-ability">
<!-- 能力/模态标签(可编辑:点 × 删除;点 + 添加;手动改后标为「手动」) -->
<div class="model-tags">
<span v-for="md in m.modalities" v-show="!redundantTags.modalities.has(md)" :key="'md-' + md"
class="tag tag-modality" :title="$t('settings.tagTooltipModality')">
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" @click.stop="removeModality(m, md)">×</span>
</span>
<span v-for="cap in m.capabilities" v-show="!redundantTags.capabilities.has(cap)" :key="'cap-' + cap"
class="tag tag-capability" :title="$t('settings.tagTooltipCapability')">
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" @click.stop="removeCapability(m, cap)">×</span>
</span>
<span v-if="m.probe_source && m.probe_source !== 'user_set'" v-show="!redundantTags.sources.has(m.probe_source)"
class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
<span v-if="m.probe_source === 'user_set'" class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.user_set') }}</span>
<span class="tag-add" :title="$t('settings.addTagTitle')" @click.stop="startAddTag(m)">+</span>
</div>
<!-- 添加能力内联菜单(点+展开;选未有的能力/模态即添加) -->
<div v-if="addingTagModel === m.model_id" class="add-tag-menu">
<span v-for="opt in addableTags(m)" :key="opt.kind + '-' + opt.key"
class="tag" :class="opt.kind === 'modality' ? 'tag-modality' : 'tag-capability'"
@click.stop="addTag(m, opt.key, opt.kind)">{{ opt.label }}</span>
<span v-if="addableTags(m).length === 0" class="add-tag-empty">{{ $t('settings.addTagNone') }}</span>
</div>
</td>
<td class="col-weight">
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
</td>
</tr>
</tbody>
</table>
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
<!-- M32: 模型列表 enabled/weight 改了未保存时显提示(关闭/切走将丢弃) -->
<div v-if="isFormDirty" class="model-list-unsaved-hint">{{ $t('settings.modelListUnsavedHint') }}</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
// ============================================================
// AI 提供商域 — 真实后端 CRUD + c 负载均衡池(enabled / weight)
// ============================================================
const { t } = useI18n()
// 懒加载 useAiStore 避免静态 import 形成循环依赖:
// ProviderPanel → stores/ai → useAiEvents → aiShared → useAppSettingsStore
// 在 Settings chunk 首次加载时,stores/ai 模块顶层代码(__bindMessages)可能触发 TDZ。
// 改为运行时动态 import,模块加载顺序不再耦合。
async function syncAiStoreProviders() {
const { useAiStore } = await import('@/stores/ai')
const aiStore = useAiStore()
await aiStore.loadProviders()
}
const emit = defineEmits<{
// 加 'success'(保存成功绿色 toast)
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
(e: 'providers-changed'): void
}>()
const props = defineProps<{
confirmDialog: (msg: string) => Promise<boolean>
}>()
const aiProviders = ref<AiProviderConfig[]>([])
// 密钥显隐切换(走查 P3-9):默认隐藏;重开表单时复位,防上一家 provider 的显钥状态延续
const showApiKey = ref(false)
// 走查 P3-10:校验失败只 toast 不定位字段——补字段级红框 + 滚动聚焦首个缺失字段。
// 仅保存必填四项(name/baseUrl/apiKey/defaultModel)做示范级实现;红框在对应输入框
// @input 时即清(用户开始修正就不该继续红着)。
const fieldErrors = reactive({ name: false, baseUrl: false, apiKey: false, defaultModel: false })
const inputName = useTemplateRef<HTMLInputElement>('inputName')
const inputBaseUrl = useTemplateRef<HTMLInputElement>('inputBaseUrl')
const inputApiKey = useTemplateRef<HTMLInputElement>('inputApiKey')
const inputDefaultModel = useTemplateRef<HTMLInputElement>('inputDefaultModel')
/** 聚焦首个红框字段(focus 默认行为即滚动到该元素,无需额外 scrollIntoView) */
function focusFirstError() {
const el = fieldErrors.name ? inputName.value
: fieldErrors.baseUrl ? inputBaseUrl.value
: fieldErrors.apiKey ? inputApiKey.value
: fieldErrors.defaultModel ? inputDefaultModel.value
: null
el?.focus()
}
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
const providerForm = reactive({
visible: false,
editId: '' as string,
name: '',
providerType: 'openai_compat',
baseUrl: '',
apiKey: '',
defaultModel: '',
// 供应商级采样温度覆盖(null=未配置,保存不带该参,主链路走默认 0.7;清空输入可能残留 '' ,保存时防御过滤)
temperature: null as number | null,
// 项 D:厂商预设下拉当前值(__custom=自定义;命中预设 id 时仅作展示与回填源头,
// 实际字段值由 onPresetChange 写入;用户手改任一字段后 presetId 不自动复位,
// 避免编辑态抖动,提交时也不读 presetId —— 以表单实际字段值为准)。
presetId: CUSTOM_PRESET_ID,
saving: false,
// F-01 阶段6:模型拉取 + 列表
fetching: false,
models: [] as ModelConfig[],
// 保存失败的持久错误横幅(非自隐 toast)。
// toast 3s 自隐易被忽略,保存失败时表单内常驻错误条 + 可操作建议,
// 直到下次保存尝试/重开表单才清除。null=无错误。
saveError: null as string | null,
// L19/M32: 表单打开/保存成功时的「已落库快照」JSON 串,用于 isFormDirty 比对。
// openProviderForm 写入初始快照,saveProvider 成功后刷新;关闭表单清空。
savedSnapshot: '' as string,
})
/**
* 冗余标签判定:全模型共有的 modality/capability/probe_source 无区分度,
* 每行都渲染会刷屏(如全部命中预设表 → 全是「预设」;对话模型默认 ToolUse → 全是「工具调用」)。
* 计算「所有模型都含」的值集合,模板渲染时跳过 → 只显示有区分度的标签。
*/
const redundantTags = computed(() => {
const models = providerForm.models
if (models.length === 0) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
const common = (pick: (m: ModelConfig) => string[]) => {
const sets = models.map(m => new Set(pick(m)))
const first = sets[0]
const result = new Set<string>()
for (const v of first) {
if (sets.every(s => s.has(v))) result.add(v)
}
return result
}
return {
modalities: common(m => m.modalities),
capabilities: common(m => m.capabilities),
// 探测来源:全模型相同的值也冗余(如全 PresetTable)
sources: (() => {
const vals = models.map(m => m.probe_source).filter((v): v is ProbeSource => !!v)
if (vals.length < 2) return new Set<string>()
return new Set([...new Set(vals)].filter(v => vals.every(x => x === v)))
})(),
}
})
/**
* L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。
* 触发场景:① 取消/关闭有改动的表单 → 二次确认放弃(L19);
* ② 模型列表 enabled/weight 改了未保存 → 表单内提示(M32)。
* 比对用 JSON.stringify(整体快照语义,简单可靠;表单字段少,性能无虞)。
* 不含 saving/fetching/saveError/visible 等 UI 态字段(只比业务字段)。
*/
const isFormDirty = computed(() => {
if (!providerForm.visible) return false
return formBusinessSnapshot() !== providerForm.savedSnapshot
})
function formBusinessSnapshot(): string {
return JSON.stringify({
name: providerForm.name,
providerType: providerForm.providerType,
baseUrl: providerForm.baseUrl,
apiKey: providerForm.apiKey,
defaultModel: providerForm.defaultModel,
temperature: providerForm.temperature,
models: providerForm.models.map(m => ({
model_id: m.model_id,
enabled: m.enabled,
weight: m.weight,
modalities: [...m.modalities],
capabilities: [...m.capabilities],
probe_source: m.probe_source,
})),
})
}
// ============================================================
// 模型能力手动标注(表格内编辑 tags;改动即标 UserSet,手动优先)
// ============================================================
/** 当前展开「添加能力」菜单的模型名;null=无展开 */
const addingTagModel = ref<string | null>(null)
/** 可添加的模态(与 tagModality 对齐;text 是对话基础模态,允许删但一般不主动加) */
const ADDABLE_MODALITIES: Modality[] = ['text', 'vision', 'audio', 'video']
/** 可添加的能力 */
const ADDABLE_CAPABILITIES: Capability[] = ['tool_use', 'embedding', 'code_gen']
/** 某模型可添加的可选 tag(模态 + 能力,排除已选的) */
function addableTags(m: ModelConfig): { key: string; kind: 'modality' | 'capability'; label: string }[] {
const mods = ADDABLE_MODALITIES
.filter(x => !m.modalities.includes(x))
.map(x => ({ key: x, kind: 'modality' as const, label: t('settings.tagModality.' + x) }))
const caps = ADDABLE_CAPABILITIES
.filter(x => !m.capabilities.includes(x))
.map(x => ({ key: x, kind: 'capability' as const, label: t('settings.tagCapability.' + x) }))
return [...mods, ...caps]
}
/** 标记模型为「手动标注」(probe_source=user_set),下次探测不覆盖 */
function markManual(m: ModelConfig) {
m.probe_source = 'user_set'
addingTagModel.value = null
}
function removeModality(m: ModelConfig, md: Modality) {
m.modalities = m.modalities.filter(x => x !== md)
markManual(m)
}
function removeCapability(m: ModelConfig, cap: Capability) {
m.capabilities = m.capabilities.filter(x => x !== cap)
markManual(m)
}
function addTag(m: ModelConfig, key: string, kind: 'modality' | 'capability') {
if (kind === 'modality') {
const md = key as Modality
if (!m.modalities.includes(md)) m.modalities = [...m.modalities, md]
} else {
const cap = key as Capability
if (!m.capabilities.includes(cap)) m.capabilities = [...m.capabilities, cap]
}
markManual(m)
}
function startAddTag(m: ModelConfig) {
addingTagModel.value = addingTagModel.value === m.model_id ? null : m.model_id
}
/**
* L19: 关闭表单入口——脏表单二次确认,清表单后关闭。
* 用于模板「取消」按钮 + saveProvider 成功后(无脏直接关)。
*/
async function closeProviderForm() {
if (isFormDirty.value) {
const ok = await props.confirmDialog(t('settings.confirmDiscardUnsaved'))
if (!ok) return // 用户取消关闭,继续编辑
}
providerForm.visible = false
providerForm.savedSnapshot = ''
}
async function loadProviders() {
try {
aiProviders.value = await aiApi.listProviders()
// 同步刷新全局 store(AiChat 等 AI 面板消费方共享同一 state,避免改完仍提示「未配置」)
// 懒加载避免循环依赖(见文件顶部 syncAiStoreProviders 注释)
await syncAiStoreProviders()
emit('providers-changed')
} catch (e) {
console.error(t('settings.toastLoadProviderFail'), e)
emit('toast', t('settings.toastLoadProviderFail'), 'error')
}
}
function openProviderForm(p?: AiProviderConfig) {
if (p) {
providerForm.editId = p.id
providerForm.name = p.name
providerForm.baseUrl = p.base_url
providerForm.apiKey = '' // 编辑不回填 api_key(list 返回 mask,留空=不改,FR-S1)
providerForm.defaultModel = p.default_model
providerForm.providerType = p.provider_type || 'openai_compat'
// 从 config JSON 解析采样温度回显(config 可能为 null/非法 JSON,防御处理兜底 null)
providerForm.temperature = (() => {
try {
const cfg = p.config ? JSON.parse(p.config) : null
const tv = cfg?.temperature
return typeof tv === 'number' && Number.isFinite(tv) ? tv : null
} catch {
return null
}
})()
// 项 D:编辑态不反推预设(避免用户已手改 baseUrl/model 却被下拉误标成某预设误导);
// 统一标"自定义",下拉仅作新建快捷填充入口。
providerForm.presetId = CUSTOM_PRESET_ID
// F-01 阶段6:回填已保存的 model_configs(深拷贝避免双向绑定污染列表源)
providerForm.models = (p.model_configs ?? []).map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
} else {
providerForm.editId = ''
providerForm.name = ''
providerForm.providerType = 'openai_compat'
providerForm.baseUrl = ''
providerForm.apiKey = ''
providerForm.defaultModel = ''
providerForm.presetId = CUSTOM_PRESET_ID
providerForm.models = []
providerForm.temperature = null
}
providerForm.fetching = false
providerForm.saveError = null // 重开表单清旧错误横幅
fieldErrors.name = fieldErrors.baseUrl = fieldErrors.apiKey = fieldErrors.defaultModel = false // 重开表单清字段红框
showApiKey.value = false // 密钥默认隐藏,不延续上次表单的显钥状态(防窥)
providerForm.visible = true
// L19/M32: 记初始快照(在 visible=true、字段回填完成后),isFormDirty 据此比对。
providerForm.savedSnapshot = formBusinessSnapshot()
}
/**
* 项 D:厂商预设下拉变更 — 命中预设则覆盖填充 providerType/baseUrl/defaultModel。
*
* 设计取舍:
* - 覆盖而非合并:选预设=用户明确意图用某厂商端点,全量填入最省心;用户随后仍可手改任一字段。
* - 不动 name/apiKey:name 用户应自取别名(避免重名"DeepSeek"×N);apiKey 是密钥预设无可填。
* - 选"自定义"不动作(仅清字段是粗暴的;用户可能误点,留原值更安全)。
* - 不复位 presetId:用户手改 baseUrl 等后下拉仍标原预设——这无副作用(提交不读 presetId),
* 强行复位反而要监听每个字段 input,过度工程。
*/
function onPresetChange() {
const preset = findPreset(providerForm.presetId)
if (!preset) return // __custom 或未知 id:不动作
providerForm.providerType = preset.providerType
providerForm.baseUrl = preset.baseUrl
providerForm.defaultModel = preset.defaultModel
}
/**
* 测试连接按钮可用条件
* - baseUrl 必填(无论新建/编辑,表单值都要有)
* - 新建态(editId 空):api_key 必填(无已保存配置可沿用)
* - 编辑态(editId 有):api_key 可空(留空=沿用已保存密钥)
*/
const canTestConnection = computed(() => {
if (!providerForm.baseUrl) return false
if (!providerForm.editId && !providerForm.apiKey) return false
return true
})
/**
* 测试连接并拉取模型列表。
* 用**表单当前值**测试(非 DB 值):新建态直接传表单值;编辑态传表单值 + editId,
* apiKey 留空时后端从已保存配置沿用密钥。一次性测试不写库。
*
* 注:本路径成功后仅更新 model_configs 视图,不落库;用户需点「保存」才会持久化
* (避免「测试中」产生半成品配置)。原 ai_fetch_models(从 DB 读 + 写回)保留,
* 供未来「已保存配置重测」场景,当前 UI 不再走它。
*/
async function fetchModels() {
if (!canTestConnection.value) {
emit('toast', t('settings.toastSaveIncomplete'), 'warning')
return
}
providerForm.fetching = true
try {
const models = await aiApi.testConnection({
providerType: providerForm.providerType,
baseUrl: providerForm.baseUrl,
apiKey: providerForm.apiKey,
providerId: providerForm.editId || null,
})
// 空列表守卫:拉到 0 个模型时保留现有模型表格(不覆盖),仅提示——
// 与后端 ai_fetch_models 空守卫同语义(空结果不清用户配置),防误清后顺手保存落库
if (models.length === 0) {
emit('toast', t('settings.toastFetchEmpty'), 'warning')
return
}
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
// enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重
// 属用户配置意图,被探测默认值复位等于静默丢配置(走查 P1-2)。
providerForm.models = models.map(m => {
const old = providerForm.models.find(o => o.model_id === m.model_id)
const isManual = old?.probe_source === 'user_set'
return {
...m,
modalities: isManual ? [...old.modalities] : [...m.modalities],
capabilities: isManual ? [...old.capabilities] : [...m.capabilities],
probe_source: isManual ? 'user_set' : m.probe_source,
enabled: old ? old.enabled : m.enabled,
weight: old ? old.weight : m.weight,
}
})
emit('toast', t('settings.toastFetchOk', { count: models.length }), 'info')
} catch (e) {
const msg = errMsg(e)
// 按 model_fetch 错误映射友好提示:超时 / 鉴权失败 / 端点不存在 / 厂商服务异常 / 不支持
let key = 'settings.toastFetchFail'
if (msg.includes('超时')) key = 'settings.fetchFailedTimeout'
else if (msg.includes('鉴权失败')) key = 'settings.fetchFailedAuth'
else if (msg.includes('端点不存在') || msg.includes('404')) key = 'settings.fetchFailedNotFound'
emit('toast', t(key, { msg }), 'error')
} finally {
providerForm.fetching = false
}
}
/**
* 把后端返回的错误文本分类成「用户可操作的建议」。
*
* 后端 commands/provider.rs 已对密钥相关失败返回友好中文 Err(含原因+建议),
* 这里只做轻量分类挂上"建议下一步",避免裸 Err 文本吓到用户:
* - keyring/迁移类:后端 Err 含「钥匙串」/「迁移」→ 指引查系统钥匙串权限
* - 其余(DB/字段/未知):透传后端文案,笼统提示重试
*
* 注:不依赖具体 Err 子串判定成败(后端 Err 语义=失败),分类仅用于建议文案。
*/
function classifySaveError(raw: string): { title: string; detail: string } {
if (raw.includes('钥匙串') || raw.includes('迁移')) {
return {
title: t('settings.saveErrKeyringTitle'),
detail: t('settings.saveErrKeyringDetail', { msg: raw }),
}
}
return {
title: t('settings.saveErrGenericTitle'),
detail: t('settings.saveErrGenericDetail', { msg: raw }),
}
}
async function saveProvider() {
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
const needKey = !providerForm.editId
// 走查 P3-10:逐字段判定缺失,红框标出并滚动聚焦首个缺失字段(toast 保留,双通道反馈)
fieldErrors.name = !providerForm.name
fieldErrors.baseUrl = !providerForm.baseUrl
fieldErrors.apiKey = needKey && !providerForm.apiKey
fieldErrors.defaultModel = !providerForm.defaultModel
if (fieldErrors.name || fieldErrors.baseUrl || fieldErrors.apiKey || fieldErrors.defaultModel) {
emit('toast', t('settings.toastSaveIncomplete'), 'warning')
focusFirstError()
return
}
// 走查 P3-14:采样温度钳制 [0,2](原生 min/max 只约束步进按钮,手输 99 照存)。
// 越界有限值 → clamp + toast 告知修正值;NaN/清空残留 → 归一 null(走默认 0.7,同步清显示)
let temperatureOverride: number | undefined
if (typeof providerForm.temperature === 'number' && Number.isFinite(providerForm.temperature)) {
if (providerForm.temperature < 0 || providerForm.temperature > 2) {
providerForm.temperature = Math.min(2, Math.max(0, providerForm.temperature))
emit('toast', t('settings.toastTempClamped', { v: providerForm.temperature }), 'warning')
}
temperatureOverride = providerForm.temperature
} else {
providerForm.temperature = null
}
// 模型权重钳制 [0,100](同温度范式:原生 min/max 只约束步进按钮,手输越界照存)。
// 越界/小数 → clamp+取整;清空残留非数值(v-model.number 得 '')→ 归一 50(对齐负载池 NaN 兜底)。
// 多行聚合一次 toast,逐行报会刷屏。
let weightClamped = false
for (const m of providerForm.models) {
if (typeof m.weight === 'number' && Number.isFinite(m.weight)) {
const c = Math.min(100, Math.max(0, Math.trunc(m.weight)))
if (c !== m.weight) { m.weight = c; weightClamped = true }
} else {
m.weight = 50
weightClamped = true
}
}
if (weightClamped) emit('toast', t('settings.toastWeightClamped'), 'warning')
providerForm.saving = true
try {
await aiApi.saveProvider({
id: providerForm.editId || undefined,
name: providerForm.name,
providerType: providerForm.providerType,
baseUrl: providerForm.baseUrl,
apiKey: providerForm.apiKey,
defaultModel: providerForm.defaultModel,
modelConfigs: providerForm.models,
temperatureOverride,
})
// 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
providerForm.saveError = null
providerForm.visible = false
providerForm.savedSnapshot = '' // L19: 关表单清快照
await loadProviders()
emit('toast', t('settings.toastSaved'), 'success')
} catch (e) {
// 失败时①表单保持编辑态(不关)②表单内常驻错误横幅(分类建议,非自隐)
// ③仍发 error toast(用户视线不在表单时仍可见),双通道确保反馈不被忽略。
const raw = errMsg(e)
console.error(t('settings.toastSaveFail', { msg: raw }), e)
const { title, detail } = classifySaveError(raw)
providerForm.saveError = title + '\n' + detail
emit('toast', t('settings.toastSaveFail', { msg: raw }), 'error')
} finally {
providerForm.saving = false
}
}
async function deleteProvider(id: string) {
const p = aiProviders.value.find(x => x.id === id)
if (!await props.confirmDialog(t('settings.confirmDeleteProvider', { name: p?.name || id }))) return
try {
await aiApi.deleteProvider(id)
await loadProviders()
emit('toast', t('settings.toastDeleted'), 'info')
} catch (e) {
console.error(t('settings.toastDeleteFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastDeleteFail', { msg: errMsg(e) }), 'error')
}
}
async function setDefaultProvider(id: string) {
try {
await aiApi.setProvider(id)
await loadProviders()
emit('toast', t('settings.toastSetDefaultOk'), 'info')
} catch (e) {
console.error(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastSetDefaultFail', { msg: errMsg(e) }), 'error')
}
}
// ============================================================
// c: 负载均衡池可编辑层(enabled / weight)
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
// 重建 per_provider caps,下条消息即按新配置 acquire。
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
// ============================================================
// 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
* 乐观更新本地数组,失败 revert + toast。
*/
async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
const prev = p.enabled
p.enabled = enabled
try {
// 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
const w = Math.min(100, Math.max(0, p.weight ?? 50))
await aiApi.updateProviderPool(p.id, enabled, w)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.enabled = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
}
/**
* weight 变更 debounce 300ms(防快速连续输入触发频繁 IPC),clamp [0,100] 对齐后端。
* 双 clamp:前端 clamp 视觉输入 + 后端 command 再 clamp(防御性)。
*/
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
const prev = p.weight
p.weight = clamped
// 仅重置同一 provider 的待发计时;跨 provider 互不干扰
const prevTimer = _poolTimers.get(p.id)
if (prevTimer) clearTimeout(prevTimer)
_poolTimers.set(p.id, setTimeout(async () => {
_poolTimers.delete(p.id)
try {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.weight = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
}, 300))
}
function maskKey(key: string): string {
if (!key || key.length < 8) return '••••••••'
return key.slice(0, 4) + '••••••••' + key.slice(-4)
}
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer。
* L19: isFormDirty + closeProviderForm 暴露,供外部(如导航切走前)查询/触发拦截。
* 当前 Settings.vue 未接入 nav 拦截(不在本次改文件集),接口先行供后续 shell 接入。 */
defineExpose({
aiProviders,
loadProviders,
isFormDirty,
closeProviderForm,
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
})
// 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用,
// 改为自身挂载时拉取 provider 列表 + 同步 aiStore。
onMounted(loadProviders)
</script>
<style scoped>
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
setting-input/toggle/toggle-slider 已迁移到 settings.css 全局。
本 scoped 仅保留 ProviderPanel 特有样式:provider-card/default-badge/
pool-badge/detail 系列/form 系列/btn-link/toggle--sm 及输入控件布局覆盖等。
注:注释列举类名时勿写星斜杠序列(会提前闭合 CSS 注释)。 */
/* ===== 链接按钮(本面板特有,未进全局) ===== */
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
.btn-link:hover { text-decoration: underline; }
.btn-link-danger { color: var(--df-danger); }
/* ===== Provider 卡片 ===== */
.provider-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card {
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 14px 16px;
}
.provider-card--default {
border-color: var(--df-accent);
box-shadow: inset 3px 0 0 var(--df-accent);
}
.provider-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.provider-info { display: flex; align-items: center; gap: 8px; }
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
.default-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(100,255,218,0.15);
color: var(--df-success);
font-weight: 500;
}
.provider-actions { display: flex; gap: 4px; }
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
.detail-label {
font-size: 12px;
color: var(--df-text-dim);
min-width: 60px;
}
.detail-value {
font-size: 12px;
color: var(--df-text-secondary);
font-family: var(--df-font-mono);
}
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
/* Provider 卡片 disabled 态(c:enabled=false 视觉弱化) */
.provider-card--disabled { opacity: 0.6; }
.pool-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(255,107,107,0.15);
color: var(--df-danger);
font-weight: 500;
}
/* 负载均衡池行(toggle / weight 控件) */
.detail-row--pool { align-items: center; }
.detail-value--control { display: flex; align-items: center; gap: 8px; font-family: var(--df-font-mono); }
.pool-weight-hint { font-size: 11px; color: var(--df-text-dim); }
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.form-field { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.form-label { flex: 0 0 72px; font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
/* 字段级辅助说明(默认模型提示等):弱化色 + 小字,与 label/input 区分层级。
横向布局下占满整行换到 label+input 下方。 */
.form-field-hint { flex-basis: 100%; margin-left: 82px; font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
/* 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */
.save-error-banner {
background: var(--df-danger-bg);
border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm);
padding: 10px 12px;
}
.save-error-msg {
margin: 0;
font-family: var(--df-font-sans);
font-size: 12px;
line-height: 1.55;
color: var(--df-danger);
white-space: pre-wrap; /* 保留 title\ndetail 换行 + 长建议自动折行 */
word-break: break-word;
}
/* 模型列表区:占整行,恢复纵向布局(label 上 + 列表下),不受 .form-field 横向化影响 */
.form-field--full { flex-direction: column; align-items: stretch; gap: 4px; }
.form-field--full .form-label { flex: none; text-align: left; margin-bottom: 4px; }
/* 输入/下拉在 form-field 内 flex 填满(基础样式走全局 settings.css .setting-input/.setting-select) */
.form-field .setting-input,
.form-field .setting-select { flex: 1; min-width: 0; }
/* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */
.setting-input--invalid { border-color: var(--df-danger) !important; }
.setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); }
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
.toggle--sm { width: 32px; height: 18px; }
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
/* M32: 模型列表有未保存调整时的提示(弱 warning 色,贴合 model-list-hint 之下) */
.model-list-unsaved-hint {
margin-top: 4px;
font-size: 11px;
line-height: 1.5;
color: var(--df-warning);
}
/* ===== 模型列表(表格形式:表头 + 数据行) =====
原 model-list/model-row 等无任何样式定义(裸渲染),本批补齐并改为表格。
风格对齐 provider-card(浅底 / 圆角 / 0.5px 边框)。 */
.model-table {
width: 100%;
border-collapse: collapse;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
overflow: hidden;
font-size: 12px;
}
.model-table th,
.model-table td {
padding: 8px 12px;
text-align: left;
vertical-align: middle;
}
.model-table thead th {
background: var(--df-bg);
font-weight: 500;
color: var(--df-text-dim);
font-size: 11px;
border-bottom: 0.5px solid var(--df-border);
white-space: nowrap;
}
.model-table tbody tr { border-bottom: 0.5px solid var(--df-border); }
.model-table tbody tr:last-child { border-bottom: none; }
.model-table tbody tr:hover { background: var(--df-accent-bg); }
/* 列宽:启用窄、模型/能力弹性、权重窄 */
.model-table .col-enabled { width: 56px; text-align: center; }
.model-table .col-weight { width: 110px; }
.model-table .col-model { min-width: 140px; }
.col-enabled .toggle--sm { display: inline-block; }
.model-id {
font-size: 13px;
font-family: var(--df-font-mono);
color: var(--df-text);
font-weight: 500;
word-break: break-all;
}
/* 标签列:小圆角 chip,三类语义色区分 */
.model-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
font-weight: 500;
white-space: nowrap;
}
.tag-modality { background: rgba(100,181,246,0.15); color: var(--df-info); }
.tag-capability { background: rgba(108,99,255,0.15); color: var(--df-accent); }
.tag-source { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
/* 可编辑 tag:×删除按钮(悬停显红)+ 添加按钮 */
.tag-remove { margin-left: 3px; cursor: pointer; opacity: 0.6; font-weight: 700; }
.tag-remove:hover { opacity: 1; color: var(--df-danger); }
.tag-add {
cursor: pointer;
background: transparent;
border: 1px dashed var(--df-border);
color: var(--df-text-dim);
font-weight: 700;
}
.tag-add:hover { border-color: var(--df-accent); color: var(--df-accent); }
/* 添加能力内联菜单(点+展开):浅底容器包可选 tag */
.add-tag-menu {
margin-top: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
gap: 4px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
}
.add-tag-menu .tag { cursor: pointer; }
.add-tag-menu .tag:hover { filter: brightness(1.2); }
.add-tag-empty { font-size: 10px; color: var(--df-text-dim); padding: 2px 4px; }
/* 权重输入框:表格内固定窄宽 */
.model-table .setting-input--sm { width: 64px; }
.model-list-hint {
margin-top: 8px;
font-size: 11px;
color: var(--df-text-dim);
line-height: 1.5;
}
</style>