新增: F-04c前端Settings池编辑(enabled toggle+weight输入+IPC绑定)

This commit is contained in:
2026-06-17 02:36:54 +08:00
parent e16d038bb2
commit 3f90aad2d6
5 changed files with 117 additions and 1 deletions

View File

@@ -138,6 +138,15 @@ export const aiApi = {
return invoke('ai_set_provider', { providerId }) return invoke('ai_set_provider', { providerId })
}, },
/**
* 更新 provider 负载均衡池配置(F-260614-04c):仅 UPDATE enabled/weight + 重建 per_provider caps。
* 走轻量分支,不经 ai_save_provider 全量 INSERT OR REPLACE(避免空 api_key 触发 R-PD-1 密钥迁移)。
* 后端 weight clamp [0,100];落库后立即 reload_provider_caps,下条消息即按新配置 acquire。
*/
updateProviderPool(providerId: string, enabled: boolean, weight: number): Promise<void> {
return invoke('ai_update_provider_pool', { providerId, enabled, weight })
},
/** 删除 AI 提供商 */ /** 删除 AI 提供商 */
deleteProvider(providerId: string): Promise<void> { deleteProvider(providerId: string): Promise<void> {
return invoke('ai_delete_provider', { providerId }) return invoke('ai_delete_provider', { providerId })

View File

@@ -187,6 +187,10 @@ export interface AiProviderConfig {
config: string | null config: string | null
created_at: string created_at: string
updated_at: string updated_at: string
/** provider 是否进入负载均衡池(F-260614-04c)。false=仅配置存在,不参与主链路由/选池。老库默认 true。 */
enabled?: boolean
/** provider 在负载均衡池中的选择权重(0-100,F-260614-04c)。高权重优先被选为主;同权重近似轮询。老库默认 50。 */
weight?: number
} }
// ============================================================ // ============================================================

View File

@@ -14,6 +14,13 @@ export default {
detailBaseUrl: 'Base URL', detailBaseUrl: 'Base URL',
detailModel: 'Model', detailModel: 'Model',
detailApiKey: 'API Key', detailApiKey: 'API Key',
detailPoolEnabled: 'In load-balance pool',
detailPoolWeight: 'Pool selection weight',
badgePoolDisabled: 'Disabled',
descPoolEnabled: 'When on, participates in main-chain routing/pool selection; off = config retained but never selected',
descPoolWeight: '0-100, higher weight preferred as primary; equal weights ~ round-robin (backend clamps [0,100])',
toastPoolUpdateOk: 'Load-balance pool config updated',
toastPoolUpdateFail: 'Failed to update pool config: {msg}',
// Provider form // Provider form
labelName: 'Name', labelName: 'Name',

View File

@@ -14,6 +14,13 @@ export default {
detailBaseUrl: 'Base URL', detailBaseUrl: 'Base URL',
detailModel: '模型', detailModel: '模型',
detailApiKey: 'API Key', detailApiKey: 'API Key',
detailPoolEnabled: '入负载均衡池',
detailPoolWeight: '池选择权重',
badgePoolDisabled: '已停用',
descPoolEnabled: '开启后参与主链路由/选池;关闭后仅保留配置,不被选中',
descPoolWeight: '0-100高权重优先被选为主同权重近似轮询后端 clamp [0,100]',
toastPoolUpdateOk: '负载均衡池配置已更新',
toastPoolUpdateFail: '更新负载均衡池配置失败:{msg}',
// Provider 表单 // Provider 表单
labelName: '名称', labelName: '名称',

View File

@@ -27,11 +27,12 @@
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button> <button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
</div> </div>
<div class="provider-list" v-if="aiProviders.length > 0"> <div class="provider-list" v-if="aiProviders.length > 0">
<div class="provider-card" :class="{ 'provider-card--default': p.is_default }" v-for="p in aiProviders" :key="p.id"> <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-top">
<div class="provider-info"> <div class="provider-info">
<span class="provider-name">{{ p.name }}</span> <span class="provider-name">{{ p.name }}</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</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>
<div class="provider-actions"> <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="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
@@ -52,6 +53,25 @@
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span> <span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span> <span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div> </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>
</div> </div>
@@ -591,6 +611,51 @@ async function setDefaultProvider(id: string) {
} }
} }
// ============================================================
// 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)
showToast(t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.enabled = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
showToast(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))
p.weight = clamped
if (_poolTimer) clearTimeout(_poolTimer)
_poolTimer = setTimeout(async () => {
try {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
showToast(t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
}, 300)
}
function maskKey(key: string): string { function maskKey(key: string): string {
if (!key || key.length < 8) return '••••••••' if (!key || key.length < 8) return '••••••••'
return key.slice(0, 4) + '••••••••' + key.slice(-4) return key.slice(0, 4) + '••••••••' + key.slice(-4)
@@ -886,6 +951,7 @@ onUnmounted(() => {
if (_agentIterTimer) clearTimeout(_agentIterTimer) if (_agentIterTimer) clearTimeout(_agentIterTimer)
if (_agentRetryTimer) clearTimeout(_agentRetryTimer) if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer) if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
if (_poolTimer) clearTimeout(_poolTimer)
if (_toastTimer) clearTimeout(_toastTimer) if (_toastTimer) clearTimeout(_toastTimer)
}) })
</script> </script>
@@ -1178,4 +1244,27 @@ onUnmounted(() => {
transform: translateX(18px); transform: translateX(18px);
background: #fff; background: #fff;
} }
/* 小号 toggle(model 行 / 池开关) */
.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); }
/* 小号输入框(model 权重 / 池权重) */
.setting-input--sm { min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
/* 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); }
</style> </style>