- E5:[补充指令]与[系统恢复]同治三处——载入滤出/恢复回放剔除/ 首条任务消息排除(434c8c40占位实证),is_guide_artifact统一判据 - E13:同工具超时连败≥3跨签名聚合,执行前软收敛警告(治换档位调参 逃逸,434c8c40 run_command 8连败实证) - E2:压缩失败走关键词摘要兜底时新发AiContextDegraded,前端toast 「上下文已降级」不再静默失忆(76730ce8 seq166实证) - vite watch忽略target/(cargo编译写.o触发EBUSY杀dev进程) - provider-list模板根div缺闭合补齐(拆分半成品断链) 验收:tool_failure 5/keyword_fallback 3/recovery 6 passed;vue-tsc EXIT 0
215 lines
9.3 KiB
Vue
215 lines
9.3 KiB
Vue
<template>
|
|
<!-- provider 卡片列表。自 ProviderPanel 拆出(C4c 纯搬迁):
|
|
卡片渲染 + 负载均衡池控件(enabled/weight 即时 IPC)自治在本件,
|
|
增删改/设默认经 emit 上抛主文件(共享 loadProviders 闭环)。 -->
|
|
<div class="provider-list">
|
|
<div class="provider-card" :class="{ 'provider-card--default': p.is_default, 'provider-card--disabled': p.enabled === false }" v-for="p in providers" :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="emit('set-default', p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
|
|
<button class="btn-link" @click="emit('edit', p)">{{ $t('common.edit') }}</button>
|
|
<button class="btn-link btn-link-danger" @click="emit('delete', 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>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useI18n } from 'vue-i18n'
|
|
import { aiApi } from '@/api'
|
|
import type { AiProviderConfig } from '@/api/types'
|
|
|
|
// ============================================================
|
|
// c: 负载均衡池可编辑层(enabled / weight)——原 ProviderPanel 整块搬迁
|
|
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
|
|
// 重建 per_provider caps,下条消息即按新配置 acquire。
|
|
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
|
|
// ============================================================
|
|
const { t } = useI18n()
|
|
|
|
defineProps<{
|
|
/** 主文件 aiProviders 引用;池控件直接改 item 字段(共享响应式对象,乐观更新),列表整体替换仍由主文件驱动 */
|
|
providers: AiProviderConfig[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'edit', p: AiProviderConfig): void
|
|
(e: 'delete', id: string): void
|
|
(e: 'set-default', id: string): void
|
|
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
|
|
}>()
|
|
|
|
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
|
|
|
|
// 计时器按 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)
|
|
}
|
|
|
|
/** 主文件 defineExpose.clearPoolTimer 委托到此处(壳卸载清 timer 的对外契约不变)。 */
|
|
defineExpose({
|
|
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 自 ProviderPanel scoped 拆出(C4c 纯搬迁):provider 卡片/池控件样式随区块走;
|
|
.setting-input--sm 与 .toggle--sm 为本件与 model-table 子件共用,两处各持一份
|
|
(scoped 不跨组件,全局化去重留待后续统一处理)。 */
|
|
|
|
/* ===== 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: var(--df-success-bg);
|
|
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: var(--df-danger-bg);
|
|
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); }
|
|
|
|
/* 行内窄输入(权重等):定高 26px 档(--df-h-input),与 .setting-input 主体高度齐平 */
|
|
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; height: var(--df-h-input); 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>
|