- temperature 按模型适配,仅收 1 的模型不再 400 - 流式 error 帧不再被 choices 必填整帧丢弃 - 只读缓存写后失效 - git diff 中文截断撞 UTF-8 边界 panic - 非视觉模型纯图片直接拒绝 - 全局快捷键补输入焦点守卫
962 lines
44 KiB
Vue
962 lines
44 KiB
Vue
<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>
|