- SettingsNav 左导航 7 类 + 顶部搜索(索引匹配 label/desc 高亮定位) - GeneralPanel 拆分 Appearance/Performance/Advanced Section - SettingRow 统一行 + 即时反馈(已保存)+ 下轮生效 badge - 统一组件库 settings.css(scoped 重复 class 抽全局) - 导入导出(默认排除 keyring 密钥) - activeCategory 持久化 + 窄屏折叠 tab
439 lines
19 KiB
Vue
439 lines
19 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>
|
|
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">{{ $t('common.cancel') }}</button>
|
|
</div>
|
|
<div class="form-grid">
|
|
<div class="form-field">
|
|
<label class="form-label">{{ $t('settings.labelName') }}</label>
|
|
<input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" />
|
|
</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 v-model="providerForm.baseUrl" class="setting-input" :placeholder="$t('settings.phBaseUrl')" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
|
|
<input v-model="providerForm.apiKey" class="setting-input" type="password" :placeholder="$t('settings.phApiKey')" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
|
|
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
|
|
</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 || !providerForm.editId">
|
|
{{ providerForm.fetching ? $t('settings.fetchingModels') : $t('settings.testConnection') }}
|
|
</button>
|
|
<span v-if="!providerForm.editId" class="form-hint">{{ $t('settings.fetchHintSaveFirst') }}</span>
|
|
</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>
|
|
<div class="model-list">
|
|
<div class="model-row" v-for="m in providerForm.models" :key="m.model_id">
|
|
<div class="model-row-main">
|
|
<label class="toggle toggle--sm">
|
|
<input type="checkbox" v-model="m.enabled" />
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
<span class="model-id">{{ m.model_id }}</span>
|
|
<div class="model-tags">
|
|
<span v-for="md in m.modalities" :key="'md-' + md" class="tag tag-modality">{{ $t('settings.tagModality.' + md) }}</span>
|
|
<span v-for="cap in m.capabilities" :key="'cap-' + cap" class="tag tag-capability">{{ $t('settings.tagCapability.' + cap) }}</span>
|
|
<span v-if="m.probe_source" class="tag tag-source">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="model-row-side">
|
|
<label class="model-weight-label">{{ $t('settings.labelWeight') }}</label>
|
|
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { reactive, ref, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { aiApi } from '@/api'
|
|
import { useAiStore } from '@/stores/ai'
|
|
import type { AiProviderConfig, ModelConfig } from '@/api/types'
|
|
|
|
// ============================================================
|
|
// AI 提供商域 — 真实后端 CRUD + F-04c 负载均衡池(enabled / weight)
|
|
// ============================================================
|
|
const { t } = useI18n()
|
|
const aiStore = useAiStore()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void
|
|
(e: 'providers-changed'): void
|
|
}>()
|
|
|
|
const props = defineProps<{
|
|
confirmDialog: (msg: string) => Promise<boolean>
|
|
}>()
|
|
|
|
const aiProviders = ref<AiProviderConfig[]>([])
|
|
|
|
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: '',
|
|
saving: false,
|
|
// F-01 阶段6:模型拉取 + 列表
|
|
fetching: false,
|
|
models: [] as ModelConfig[],
|
|
})
|
|
|
|
async function loadProviders() {
|
|
try {
|
|
aiProviders.value = await aiApi.listProviders()
|
|
// 同步刷新全局 store(AiChat 等 AI 面板消费方共享同一 state,避免改完仍提示「未配置」)
|
|
await aiStore.loadProviders()
|
|
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'
|
|
// 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.models = []
|
|
}
|
|
providerForm.fetching = false
|
|
providerForm.visible = true
|
|
}
|
|
|
|
/**
|
|
* 测试连接并拉取模型列表(F-01 阶段6)。
|
|
* 后端 ai_fetch_models:DB 取 provider + 内存解析 api_key(FR-S1)+ 网络拉取 + 探测 4 维度 + 写回。
|
|
* 新建态(editId 空)无 provider_id → 按钮已 disabled,提示先保存。
|
|
*
|
|
* enabled toggle / weight input 为本地会话内编辑(仅本批展示交互,持久化另见 issues);
|
|
* 下次「测试连接」会以最新探测结果覆盖 model_configs 落库。
|
|
*/
|
|
async function fetchModels() {
|
|
if (!providerForm.editId) {
|
|
emit('toast', t('settings.fetchHintSaveFirst'), 'warning')
|
|
return
|
|
}
|
|
providerForm.fetching = true
|
|
try {
|
|
const models = await aiApi.fetchModels(providerForm.editId)
|
|
providerForm.models = models.map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
|
|
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
|
|
}
|
|
}
|
|
|
|
async function saveProvider() {
|
|
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
|
|
const needKey = !providerForm.editId
|
|
if (!providerForm.name || !providerForm.baseUrl || (needKey && !providerForm.apiKey) || !providerForm.defaultModel) {
|
|
emit('toast', t('settings.toastSaveIncomplete'), 'warning')
|
|
return
|
|
}
|
|
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,
|
|
})
|
|
providerForm.visible = false
|
|
await loadProviders()
|
|
emit('toast', t('settings.toastSaved'), 'info')
|
|
} catch (e) {
|
|
console.error(t('settings.toastSaveFail', { msg: errMsg(e) }), e)
|
|
emit('toast', t('settings.toastSaveFail', { msg: errMsg(e) }), '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')
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// F-260614-04c: 负载均衡池可编辑层(enabled / weight)
|
|
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
|
|
// 重建 per_provider caps,下条消息即按新配置 acquire。
|
|
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 F-04c 轻量 UPDATE 路径冲突)。
|
|
// ============================================================
|
|
let _poolTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
/**
|
|
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
|
|
* 乐观更新本地数组,失败 revert + toast。
|
|
*/
|
|
async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
|
|
const prev = p.enabled
|
|
p.enabled = enabled
|
|
try {
|
|
await aiApi.updateProviderPool(p.id, enabled, p.weight ?? 50)
|
|
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
|
|
if (_poolTimer) clearTimeout(_poolTimer)
|
|
_poolTimer = setTimeout(async () => {
|
|
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 */
|
|
defineExpose({
|
|
aiProviders,
|
|
loadProviders,
|
|
clearPoolTimer() { if (_poolTimer) clearTimeout(_poolTimer) },
|
|
})
|
|
|
|
// 阶段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/
|
|
toggle/toggle-slider 已迁移到 settings.css 全局。
|
|
本 scoped 仅保留 ProviderPanel 特有样式:provider-card/default-badge/
|
|
pool-badge/detail 系列/form 系列/setting-input/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 态(F-260614-04c: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-direction: column; gap: 4px; }
|
|
.form-label { font-size: 12px; font-weight: 500; color: var(--df-text-secondary); }
|
|
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
|
|
|
|
.setting-input { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; min-width: 260px; }
|
|
.setting-input:focus { border-color: var(--df-accent); }
|
|
.setting-input--sm { 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); }
|
|
</style>
|