重构: Settings拆4子组件(Provider/Connection/General/Knowledge·1042行瘦身零行为变化)
This commit is contained in:
262
src/components/settings/ConnectionPanel.vue
Normal file
262
src/components/settings/ConnectionPanel.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<!-- ═══ 连接管理 ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('settings.panelConnection') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="openConnForm()">{{ $t('settings.addConnection') }}</button>
|
||||
</div>
|
||||
<div class="connection-list" v-if="connections.length > 0">
|
||||
<div class="connection-card" v-for="conn in connections" :key="conn.id">
|
||||
<div class="conn-left">
|
||||
<span class="conn-icon">{{ typeIcon(conn.type) }}</span>
|
||||
<div class="conn-info">
|
||||
<div class="conn-name-row">
|
||||
<span class="conn-name">{{ conn.name }}</span>
|
||||
<span class="conn-type" :class="'type-' + conn.type">{{ typeLabel(conn.type) }}</span>
|
||||
</div>
|
||||
<span class="conn-host">{{ conn.host }}{{ conn.port ? ':' + conn.port : '' }}</span>
|
||||
<span class="conn-user" v-if="conn.user">{{ conn.user }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-actions">
|
||||
<button class="btn-link" @click="openConnForm(conn)">{{ $t('common.edit') }}</button>
|
||||
<button class="btn-link btn-link-danger" @click="removeConn(conn.id)">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-hint" v-else>{{ $t('settings.emptyConnection') }}</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ 连接表单 ═══ -->
|
||||
<section class="panel" v-if="connForm.visible">
|
||||
<div class="panel-header">
|
||||
<h2>{{ connForm.editId ? $t('settings.editConnectionTitle') : $t('settings.addConnectionTitle') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="connForm.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="connForm.name" class="setting-input" :placeholder="$t('settings.phConnName')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelType') }}</label>
|
||||
<select v-model="connForm.type" class="setting-select">
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="redis">Redis</option>
|
||||
<option value="mongo">MongoDB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelHost') }}</label>
|
||||
<input v-model="connForm.host" class="setting-input" :placeholder="$t('settings.phHost')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelPort') }}</label>
|
||||
<input v-model.number="connForm.port" class="setting-input" type="number" :placeholder="$t('settings.phPort')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelUser') }}</label>
|
||||
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" @click="saveConn">{{ $t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
// ============================================================
|
||||
// 连接管理域 — localStorage CRUD
|
||||
// ============================================================
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
confirmDialog: (msg: string) => Promise<boolean>
|
||||
}>()
|
||||
|
||||
interface ConnRecord {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
}
|
||||
|
||||
const connections = ref<ConnRecord[]>([])
|
||||
|
||||
const connForm = reactive({
|
||||
visible: false,
|
||||
editId: '' as string,
|
||||
name: '',
|
||||
type: 'mysql',
|
||||
host: '',
|
||||
port: 3306,
|
||||
user: '',
|
||||
})
|
||||
|
||||
const CONN_STORAGE_KEY = 'df-connections'
|
||||
|
||||
function loadConnections() {
|
||||
const stored = appSettings.get<ConnRecord[] | null>(CONN_STORAGE_KEY, null)
|
||||
if (stored) connections.value = stored
|
||||
}
|
||||
|
||||
function persistConnections() {
|
||||
void appSettings.set(CONN_STORAGE_KEY, connections.value)
|
||||
}
|
||||
|
||||
function typeIcon(type: string) {
|
||||
const map: Record<string, string> = { mysql: '🗄️', ssh: '🔐', redis: '⚡', mongo: '🍃' }
|
||||
return map[type] || '🔗'
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
const map: Record<string, string> = { mysql: 'MySQL', ssh: 'SSH', redis: 'Redis', mongo: 'MongoDB' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function openConnForm(conn?: ConnRecord) {
|
||||
if (conn) {
|
||||
connForm.editId = conn.id
|
||||
connForm.name = conn.name
|
||||
connForm.type = conn.type
|
||||
connForm.host = conn.host
|
||||
connForm.port = conn.port
|
||||
connForm.user = conn.user
|
||||
} else {
|
||||
connForm.editId = ''
|
||||
connForm.name = ''
|
||||
connForm.type = 'mysql'
|
||||
connForm.host = ''
|
||||
connForm.port = 3306
|
||||
connForm.user = ''
|
||||
}
|
||||
connForm.visible = true
|
||||
}
|
||||
|
||||
function saveConn() {
|
||||
if (!connForm.name || !connForm.host) {
|
||||
emit('toast', t('settings.toastConnIncomplete'), 'warning')
|
||||
return
|
||||
}
|
||||
const record: ConnRecord = {
|
||||
id: connForm.editId || Date.now().toString(36),
|
||||
name: connForm.name,
|
||||
type: connForm.type,
|
||||
host: connForm.host,
|
||||
port: connForm.port,
|
||||
user: connForm.user,
|
||||
}
|
||||
if (connForm.editId) {
|
||||
const idx = connections.value.findIndex(c => c.id === connForm.editId)
|
||||
if (idx >= 0) connections.value[idx] = record
|
||||
} else {
|
||||
connections.value.push(record)
|
||||
}
|
||||
persistConnections()
|
||||
connForm.visible = false
|
||||
}
|
||||
|
||||
async function removeConn(id: string) {
|
||||
const c = connections.value.find(x => x.id === id)
|
||||
if (!await props.confirmDialog(t('settings.confirmDeleteConn', { name: c?.name || id }))) return
|
||||
connections.value = connections.value.filter(c => c.id !== id)
|
||||
persistConnections()
|
||||
emit('toast', t('settings.toastDeleted'), 'info')
|
||||
}
|
||||
|
||||
/** 暴露给 shell onMounted 调用 */
|
||||
defineExpose({ loadConnections })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== 面板容器(原 Settings.vue 抽出,各子组件自持 scoped 副本) ===== */
|
||||
.panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.panel-header h2 { font-size: 15px; font-weight: 500; }
|
||||
.empty-hint { font-size: 13px; color: var(--df-text-dim); padding: 12px 0; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: all 0.15s; }
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
.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); }
|
||||
|
||||
/* ===== 连接卡片 ===== */
|
||||
.connection-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.connection-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.conn-left { display: flex; align-items: center; gap: 10px; }
|
||||
.conn-icon { font-size: 18px; }
|
||||
.conn-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.conn-name-row { display: flex; align-items: center; gap: 8px; }
|
||||
.conn-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.conn-type {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
.type-ssh { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.type-redis { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||||
.type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
|
||||
.conn-host, .conn-user {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.conn-user {
|
||||
background: rgba(90,99,128,0.2);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.conn-actions { display: flex; gap: 4px; }
|
||||
|
||||
/* 表单 */
|
||||
.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-select { 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; cursor: pointer; min-width: 160px; }
|
||||
.setting-select:focus { border-color: var(--df-accent); }
|
||||
</style>
|
||||
322
src/components/settings/GeneralPanel.vue
Normal file
322
src/components/settings/GeneralPanel.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<!-- ═══ 通用设置 ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('settings.panelGeneral') }}</h2>
|
||||
</div>
|
||||
<div class="general-settings">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelTheme') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descTheme') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.theme" class="setting-select">
|
||||
<option value="dark">{{ $t('settings.themeDark') }}</option>
|
||||
<option value="light">{{ $t('settings.themeLight') }}</option>
|
||||
<option value="system">{{ $t('settings.themeSystem') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelLanguage') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descLanguage') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.language" class="setting-select">
|
||||
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
|
||||
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAiLanguage') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAiLanguage') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.aiLanguage" class="setting-select">
|
||||
<option value="auto">{{ $t('settings.aiLanguageAuto') }}</option>
|
||||
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
|
||||
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAutoExecute') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAutoExecute') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="settings.autoExecute" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelLogLevel') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descLogLevel') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.logLevel" class="setting-select">
|
||||
<option value="error">Error</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="debug">Debug</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelShowTokenUsage') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descShowTokenUsage') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="settings.showTokenUsage" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelGlobalConcurrency') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descGlobalConcurrency') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" class="setting-select" min="1" style="width:80px"
|
||||
v-model.number="settings.llmGlobalConcurrency"
|
||||
@change="syncConcurrencyConfig" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelPerConvConcurrency') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descPerConvConcurrency') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" class="setting-select" min="1" style="width:80px"
|
||||
v-model.number="settings.llmPerConvConcurrency"
|
||||
@change="syncConcurrencyConfig" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAgentMaxIterations') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAgentMaxIterations') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" class="setting-select" min="1" max="50" style="width:80px"
|
||||
v-model.number="settings.agentMaxIterations"
|
||||
@change="syncAgentMaxIterations" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAgentMaxRetries') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAgentMaxRetries') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" class="setting-select" min="0" max="10" style="width:80px"
|
||||
v-model.number="settings.agentMaxRetries"
|
||||
@change="syncAgentMaxRetries" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
// ============================================================
|
||||
// 通用设置域 — 本地设置(localStorage):主题/语言/AI语言/并发/Agent 轮次等
|
||||
// ============================================================
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
const settings = reactive({
|
||||
theme: appSettings.get<string>('df-theme', 'dark'),
|
||||
language: appSettings.get<string>('df-language', 'zh-CN'),
|
||||
aiLanguage: appSettings.get<string>('df-ai-language', 'auto'),
|
||||
autoExecute: false,
|
||||
logLevel: 'info',
|
||||
showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false),
|
||||
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
|
||||
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
|
||||
// F-260616-01: Agentic 循环最大轮次,默认 10(与后端 DEFAULT_MAX_AGENT_ITERATIONS 对齐)
|
||||
agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10),
|
||||
// F-260616-07: 流式失败重试次数,默认 3(与后端 DEFAULT_MAX_AGENT_RETRIES 对齐)
|
||||
agentMaxRetries: appSettings.get<number>('df-ai-agent-max-retries', 3),
|
||||
})
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light')
|
||||
} else if (theme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => settings.theme, (val) => {
|
||||
applyTheme(val)
|
||||
void appSettings.set('df-theme', val)
|
||||
})
|
||||
|
||||
watch(() => settings.aiLanguage, (val) => {
|
||||
void appSettings.set('df-ai-language', val)
|
||||
})
|
||||
|
||||
watch(() => settings.showTokenUsage, (val) => {
|
||||
void appSettings.set('df-show-token-usage', val)
|
||||
})
|
||||
|
||||
watch(() => settings.llmGlobalConcurrency, (v) => {
|
||||
void appSettings.set('df-llm-global-concurrency', v)
|
||||
})
|
||||
|
||||
watch(() => settings.llmPerConvConcurrency, (v) => {
|
||||
void appSettings.set('df-llm-per-conv-concurrency', v)
|
||||
})
|
||||
|
||||
watch(() => settings.agentMaxIterations, (v) => {
|
||||
void appSettings.set('df-ai-agent-max-iterations', v)
|
||||
})
|
||||
|
||||
watch(() => settings.agentMaxRetries, (v) => {
|
||||
void appSettings.set('df-ai-agent-max-retries', v)
|
||||
})
|
||||
|
||||
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
|
||||
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
|
||||
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function syncConcurrencyConfig() {
|
||||
// clamp 到合理区间,防越界输入(缓存被篡改 / 手动输超限)致限流失效;
|
||||
// 修正后值同步回 v-model(input 显示) + watch 持久化
|
||||
// 下限 1,无上限;约束单对话并发不超过全局并发
|
||||
settings.llmGlobalConcurrency = Math.max(1, settings.llmGlobalConcurrency || 3)
|
||||
settings.llmPerConvConcurrency = Math.max(1, settings.llmPerConvConcurrency || 2)
|
||||
if (settings.llmPerConvConcurrency > settings.llmGlobalConcurrency) {
|
||||
settings.llmPerConvConcurrency = settings.llmGlobalConcurrency
|
||||
}
|
||||
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
|
||||
_concurrencyTimer = setTimeout(async () => {
|
||||
try {
|
||||
await aiApi.setConcurrencyConfig(settings.llmGlobalConcurrency, settings.llmPerConvConcurrency)
|
||||
} catch (e) {
|
||||
console.error('同步 LLM 并发配置失败:', e)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 同步 Agentic 最大轮次到后端(debounce 300ms)
|
||||
// F-260616-01: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
|
||||
// 下次发消息才生效(与 llm_concurrency 传 Arc 实时反映的区别)。
|
||||
// 双 clamp:此处 1-50 + 后端 command 再 clamp 1-50,防越界输入致 loop 失效/失控
|
||||
let _agentIterTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function syncAgentMaxIterations() {
|
||||
// clamp 1-50,防越界(过小致 agent 失能、过大致烧 token)
|
||||
settings.agentMaxIterations = Math.min(50, Math.max(1, settings.agentMaxIterations || 10))
|
||||
if (_agentIterTimer) clearTimeout(_agentIterTimer)
|
||||
_agentIterTimer = setTimeout(async () => {
|
||||
try {
|
||||
await aiApi.setAgentMaxIterations(settings.agentMaxIterations)
|
||||
} catch (e) {
|
||||
console.error('同步 Agent 最大轮次失败:', e)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 同步流式失败重试次数到后端(debounce 300ms)
|
||||
// F-260616-07: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
|
||||
// 下次发消息才生效。双 clamp:此处 0-10 + 后端 command 再 clamp 0-10
|
||||
let _agentRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function syncAgentMaxRetries() {
|
||||
// clamp 0-10,防越界
|
||||
settings.agentMaxRetries = Math.min(10, Math.max(0, settings.agentMaxRetries ?? 3))
|
||||
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
|
||||
_agentRetryTimer = setTimeout(async () => {
|
||||
try {
|
||||
await aiApi.setAgentMaxRetries(settings.agentMaxRetries)
|
||||
} catch (e) {
|
||||
console.error('同步流式重试次数失败:', e)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(() => settings.language, (val) => {
|
||||
void appSettings.set('df-language', val)
|
||||
// 同步 i18n 实例,让界面立即跟随语言切换
|
||||
i18n.global.locale.value = val as 'zh-CN' | 'en'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme(settings.theme)
|
||||
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
|
||||
syncConcurrencyConfig()
|
||||
// F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值)
|
||||
syncAgentMaxIterations()
|
||||
// F-260616-07: 启动同步一次流式失败重试次数(防刷新页面后后端未恢复用户设定值)
|
||||
syncAgentMaxRetries()
|
||||
})
|
||||
|
||||
// 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3)
|
||||
onUnmounted(() => {
|
||||
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
|
||||
if (_agentIterTimer) clearTimeout(_agentIterTimer)
|
||||
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== 面板容器(原 Settings.vue 抽出,各子组件自持 scoped 副本) ===== */
|
||||
.panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.panel-header h2 { font-size: 15px; font-weight: 500; }
|
||||
|
||||
/* ===== 通用设置 ===== */
|
||||
.general-settings { display: flex; flex-direction: column; }
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.setting-desc { font-size: 12px; color: var(--df-text-dim); }
|
||||
|
||||
.setting-control { display: flex; align-items: center; }
|
||||
|
||||
.setting-select { 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; cursor: pointer; min-width: 160px; }
|
||||
.setting-select:focus { border-color: var(--df-accent); }
|
||||
|
||||
/* Toggle */
|
||||
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
|
||||
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
|
||||
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
|
||||
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
|
||||
</style>
|
||||
213
src/components/settings/KnowledgePanel.vue
Normal file
213
src/components/settings/KnowledgePanel.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('settings.panelKnowledge') }}</h2>
|
||||
</div>
|
||||
<div class="general-settings">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAutoExtract') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAutoExtract') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="knowledgeConfig.auto_extract" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelTriggerMode') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descTriggerMode') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="knowledgeConfig.trigger_mode" class="setting-select">
|
||||
<option value="on_complete">{{ $t('settings.triggerOnComplete') }}</option>
|
||||
<option value="on_idle">{{ $t('settings.triggerOnIdle') }}</option>
|
||||
<option value="manual_only">{{ $t('settings.triggerManualOnly') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelMinMessages') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descMinMessages') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-select" style="width:80px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelIdleTimeout') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descIdleTimeout') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" min="5" max="600" :value="Math.round(knowledgeConfig.idle_timeout_ms / 1000)" @input="knowledgeConfig.idle_timeout_ms = ($event.target as HTMLInputElement).valueAsNumber * 1000 || 30000" class="setting-select" style="width:80px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAutoInject') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAutoInject') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="knowledgeConfig.auto_inject" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelVectorEnabled') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descVectorEnabled') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="knowledgeConfig.vector_enabled" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" v-if="knowledgeConfig.vector_enabled">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelEmbeddingProvider') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descEmbeddingProvider') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="knowledgeConfig.embedding_provider_id" class="setting-select">
|
||||
<option :value="null">{{ $t('settings.embeddingProviderNone') }}</option>
|
||||
<option v-for="p in embeddingProviders" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" v-if="knowledgeConfig.vector_enabled">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelEmbeddingModel') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descEmbeddingModel') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" class="setting-select" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { knowledgeApi } from '@/api'
|
||||
import type { AiProviderConfig } from '@/api/types'
|
||||
|
||||
// ============================================================
|
||||
// 知识库配置域(提取 + 注入) — 走后端 IPC,非 localStorage
|
||||
// ============================================================
|
||||
const props = defineProps<{
|
||||
/** Provider 列表(embedding provider 下拉依赖,由 ProviderPanel 上抛同步) */
|
||||
aiProviders: AiProviderConfig[]
|
||||
}>()
|
||||
|
||||
const knowledgeConfig = reactive({
|
||||
loaded: false,
|
||||
auto_extract: true,
|
||||
trigger_mode: 'on_complete' as 'on_complete' | 'on_idle' | 'manual_only',
|
||||
min_messages: 4,
|
||||
idle_timeout_ms: 30000,
|
||||
auto_inject: true,
|
||||
vector_enabled: false,
|
||||
embedding_provider_id: null as string | null,
|
||||
embedding_model: null as string | null,
|
||||
})
|
||||
|
||||
// 可做 embedding 的 provider(仅 openai_compat,Anthropic 无 embed API)
|
||||
const embeddingProviders = computed(() =>
|
||||
props.aiProviders.filter(p => p.provider_type !== 'anthropic')
|
||||
)
|
||||
|
||||
async function loadKnowledgeConfig() {
|
||||
try {
|
||||
const cfg = await knowledgeApi.getConfig()
|
||||
Object.assign(knowledgeConfig, cfg)
|
||||
knowledgeConfig.loaded = true
|
||||
} catch (e) {
|
||||
console.error('加载知识库配置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
let _knowledgeSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function saveKnowledgeConfigDebounced() {
|
||||
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
|
||||
_knowledgeSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const { loaded, ...cfg } = knowledgeConfig
|
||||
void loaded
|
||||
await knowledgeApi.saveConfig(cfg as any)
|
||||
} catch (e) {
|
||||
console.error('保存知识库配置失败:', e)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({ ...knowledgeConfig }),
|
||||
() => {
|
||||
if (knowledgeConfig.loaded) saveKnowledgeConfigDebounced()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadKnowledgeConfig()
|
||||
})
|
||||
|
||||
// 组件卸载清 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3)
|
||||
onUnmounted(() => {
|
||||
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== 面板容器(原 Settings.vue 抽出,各子组件自持 scoped 副本) ===== */
|
||||
.panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.panel-header h2 { font-size: 15px; font-weight: 500; }
|
||||
|
||||
/* ===== 通用设置行(Knowledge 复用 setting-row 布局) ===== */
|
||||
.general-settings { display: flex; flex-direction: column; }
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.setting-desc { font-size: 12px; color: var(--df-text-dim); }
|
||||
|
||||
.setting-control { display: flex; align-items: center; }
|
||||
|
||||
.setting-select { 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; cursor: pointer; min-width: 160px; }
|
||||
.setting-select:focus { border-color: var(--df-accent); }
|
||||
|
||||
/* Toggle */
|
||||
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
|
||||
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
|
||||
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
|
||||
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
|
||||
</style>
|
||||
458
src/components/settings/ProviderPanel.vue
Normal file
458
src/components/settings/ProviderPanel.vue
Normal file
@@ -0,0 +1,458 @@
|
||||
<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 class="tag tag-cost" :class="'tag-cost--' + m.cost_tier">{{ $t('settings.tagCost.' + m.cost_tier) }}</span>
|
||||
<span class="tag tag-intel" :class="'tag-intel--' + m.intelligence">{{ $t('settings.tagIntel.' + m.intelligence) }}</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 } 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,
|
||||
})
|
||||
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))
|
||||
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) {
|
||||
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:onMounted 拉取 / Knowledge 读 embeddingProviders / 卸载清 timer */
|
||||
defineExpose({
|
||||
aiProviders,
|
||||
loadProviders,
|
||||
clearPoolTimer() { if (_poolTimer) clearTimeout(_poolTimer) },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== 面板容器(原 Settings.vue 抽出,各子组件自持 scoped 副本) ===== */
|
||||
.panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.panel-header h2 { font-size: 15px; font-weight: 500; }
|
||||
.empty-hint { font-size: 13px; color: var(--df-text-dim); padding: 12px 0; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: all 0.15s; }
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
.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); }
|
||||
|
||||
/* 表单 */
|
||||
.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; }
|
||||
.setting-select { 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; cursor: pointer; min-width: 160px; }
|
||||
.setting-select:focus { border-color: var(--df-accent); }
|
||||
|
||||
/* Toggle */
|
||||
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
|
||||
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
|
||||
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
|
||||
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
|
||||
.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>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user