新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -1,82 +1,96 @@
<template>
<div class="settings">
<Transition name="toast">
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
</Transition>
<Transition name="confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ $t('common.delete') }}</button>
</div>
</div>
</div>
</Transition>
<!-- 页面头部 -->
<header class="page-header">
<h1> 设置</h1>
<h1>{{ $t('settings.title') }}</h1>
</header>
<div class="settings-grid">
<!-- AI 模型配置 -->
<section class="panel">
<div class="panel-header">
<h2>🤖 AI 模型配置</h2>
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">+ 添加 Provider</button>
<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" v-for="p in aiProviders" :key="p.id">
<div class="provider-card" :class="{ 'provider-card--default': p.is_default }" 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">默认</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
</div>
<div class="provider-actions">
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">设为默认</button>
<button class="btn-link" @click="openProviderForm(p)">编辑</button>
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">删除</button>
<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">Base URL</span>
<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">模型</span>
<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">API Key</span>
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div>
</div>
</div>
</div>
<div class="empty-hint" v-else>暂无 AI 提供商点击上方按钮添加</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 ? '编辑 Provider' : '添加 Provider' }}</h2>
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">取消</button>
<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">名称</label>
<input v-model="providerForm.name" class="setting-input" placeholder="如 DeepSeek、GLM-4" />
<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">协议类型</label>
<label class="form-label">{{ $t('settings.labelProviderType') }}</label>
<select v-model="providerForm.providerType" class="setting-select">
<option value="openai_compat">OpenAI 兼容DeepSeek / GLM v4 / OpenAI</option>
<option value="anthropic">Anthropic 兼容GLM 订阅 / Claude</option>
<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">Base URL</label>
<input v-model="providerForm.baseUrl" class="setting-input" placeholder="https://api.deepseek.com" />
<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">API Key</label>
<input v-model="providerForm.apiKey" class="setting-input" type="password" placeholder="sk-..." />
<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">默认模型</label>
<input v-model="providerForm.defaultModel" class="setting-input" placeholder="deepseek-chat" />
<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 ? '保存中...' : '保存' }}
{{ providerForm.saving ? $t('settings.saving') : $t('common.save') }}
</button>
</div>
</div>
@@ -85,8 +99,8 @@
<!-- 连接管理 -->
<section class="panel">
<div class="panel-header">
<h2>🔗 连接管理</h2>
<button class="btn btn-ghost btn-sm" @click="openConnForm()">+ 添加连接</button>
<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">
@@ -102,27 +116,27 @@
</div>
</div>
<div class="conn-actions">
<button class="btn-link" @click="openConnForm(conn)">编辑</button>
<button class="btn-link btn-link-danger" @click="removeConn(conn.id)">删除</button>
<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>暂无连接点击上方按钮添加</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 ? '编辑连接' : '添加连接' }}</h2>
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">取消</button>
<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">名称</label>
<input v-model="connForm.name" class="setting-input" placeholder="flux_dev" />
<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">类型</label>
<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>
@@ -131,19 +145,19 @@
</select>
</div>
<div class="form-field">
<label class="form-label">Host</label>
<input v-model="connForm.host" class="setting-input" placeholder="127.0.0.1" />
<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">Port</label>
<input v-model.number="connForm.port" class="setting-input" type="number" placeholder="3306" />
<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">用户名</label>
<input v-model="connForm.user" class="setting-input" placeholder="root" />
<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">保存</button>
<button class="btn btn-primary" @click="saveConn">{{ $t('common.save') }}</button>
</div>
</div>
</section>
@@ -151,51 +165,51 @@
<!-- 通用设置 -->
<section class="panel">
<div class="panel-header">
<h2>🎨 通用设置</h2>
<h2>{{ $t('settings.panelGeneral') }}</h2>
</div>
<div class="general-settings">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">主题</span>
<span class="setting-desc">深色 / 浅色模式</span>
<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">🌙 深色模式</option>
<option value="light"> 浅色模式</option>
<option value="system">💻 跟随系统</option>
<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">语言</span>
<span class="setting-desc">界面显示语言</span>
<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">简体中文</option>
<option value="en">English</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">AI 对话语言</span>
<span class="setting-desc">AI 回复使用的默认语言</span>
<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">🔄 跟随界面语言</option>
<option value="zh-CN">🇨🇳 简体中文</option>
<option value="en">🇺🇸 English</option>
<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">AI 自动执行</span>
<span class="setting-desc">AI 可自动执行低风险操作</span>
<span class="setting-label">{{ $t('settings.labelAutoExecute') }}</span>
<span class="setting-desc">{{ $t('settings.descAutoExecute') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
@@ -206,8 +220,8 @@
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">日志级别</span>
<span class="setting-desc">工作流日志详细程度</span>
<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">
@@ -218,6 +232,136 @@
</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>
</section>
<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>
</div>
@@ -225,17 +369,55 @@
</template>
<script setup lang="ts">
import { reactive, ref, watch, onMounted } from 'vue'
import { aiApi } from '../api'
import { reactive, ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi, knowledgeApi } from '../api'
import { useAppSettingsStore } from '../stores/appSettings'
import type { AiProviderConfig } from '../api/types'
import i18n from '../i18n'
const { t } = useI18n()
const appSettings = useAppSettingsStore()
// ============================================================
// AI 提供商 — 真实后端 CRUD
// ============================================================
const aiProviders = ref<AiProviderConfig[]>([])
// 顶部轻量提示(错误/警告/完成3s 自动消失;零依赖,替代未接入的 Arco Message
const toast = reactive({ visible: false, msg: '', type: 'info' as 'error' | 'warning' | 'info' })
let _toastTimer: ReturnType<typeof setTimeout> | null = null
/** 安全提取错误消息——Tauri IPC 错误是字符串非 Error 对象 */
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
function showToast(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
toast.msg = msg
toast.type = type
toast.visible = true
if (_toastTimer) clearTimeout(_toastTimer)
_toastTimer = setTimeout(() => { toast.visible = false }, 3000)
}
// 自建确认弹层(替代 window.confirm —— 后者带 webview 来源域名/端口,无法去除)
const confirmState = reactive({
visible: false,
msg: '',
resolve: null as null | ((v: boolean) => void),
})
function confirmDialog(msg: string): Promise<boolean> {
return new Promise(resolve => {
confirmState.msg = msg
confirmState.visible = true
confirmState.resolve = resolve
})
}
function answerConfirm(ok: boolean) {
confirmState.visible = false
confirmState.resolve?.(ok)
confirmState.resolve = null
}
const providerForm = reactive({
visible: false,
editId: '' as string,
@@ -251,7 +433,8 @@ async function loadProviders() {
try {
aiProviders.value = await aiApi.listProviders()
} catch (e) {
console.error('加载提供商失败:', e)
console.error(t('settings.toastLoadProviderFail'), e)
showToast(t('settings.toastLoadProviderFail'), 'error')
}
}
@@ -275,7 +458,10 @@ function openProviderForm(p?: AiProviderConfig) {
}
async function saveProvider() {
if (!providerForm.name || !providerForm.baseUrl || !providerForm.apiKey || !providerForm.defaultModel) return
if (!providerForm.name || !providerForm.baseUrl || !providerForm.apiKey || !providerForm.defaultModel) {
showToast(t('settings.toastSaveIncomplete'), 'warning')
return
}
providerForm.saving = true
try {
await aiApi.saveProvider({
@@ -288,25 +474,36 @@ async function saveProvider() {
})
providerForm.visible = false
await loadProviders()
showToast(t('settings.toastSaved'), 'info')
} catch (e) {
console.error('保存提供商失败:', e)
console.error(t('settings.toastSaveFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastSaveFail', { msg: errMsg(e) }), 'error')
} finally {
providerForm.saving = false
}
}
async function deleteProvider(id: string) {
// 暂无后端 delete 命令,通过 save 空 id 不行,需要后端支持
// 先从前端移除,后续补 delete IPC
aiProviders.value = aiProviders.value.filter(p => p.id !== id)
const p = aiProviders.value.find(x => x.id === id)
if (!await confirmDialog(t('settings.confirmDeleteProvider', { name: p?.name || id }))) return
try {
await aiApi.deleteProvider(id)
await loadProviders()
showToast(t('settings.toastDeleted'), 'info')
} catch (e) {
console.error(t('settings.toastDeleteFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastDeleteFail', { msg: errMsg(e) }), 'error')
}
}
async function setDefaultProvider(id: string) {
try {
await aiApi.setProvider(id)
await loadProviders()
showToast(t('settings.toastSetDefaultOk'), 'info')
} catch (e) {
console.error('设置默认提供商失败:', e)
console.error(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), 'error')
}
}
@@ -343,14 +540,12 @@ const connForm = reactive({
const CONN_STORAGE_KEY = 'df-connections'
function loadConnections() {
try {
const raw = localStorage.getItem(CONN_STORAGE_KEY)
if (raw) connections.value = JSON.parse(raw)
} catch { /* ignore */ }
const stored = appSettings.get<ConnRecord[] | null>(CONN_STORAGE_KEY, null)
if (stored) connections.value = stored
}
function persistConnections() {
localStorage.setItem(CONN_STORAGE_KEY, JSON.stringify(connections.value))
void appSettings.set(CONN_STORAGE_KEY, connections.value)
}
function typeIcon(type: string) {
@@ -383,7 +578,10 @@ function openConnForm(conn?: ConnRecord) {
}
function saveConn() {
if (!connForm.name || !connForm.host) return
if (!connForm.name || !connForm.host) {
showToast(t('settings.toastConnIncomplete'), 'warning')
return
}
const record: ConnRecord = {
id: connForm.editId || Date.now().toString(36),
name: connForm.name,
@@ -402,9 +600,12 @@ function saveConn() {
connForm.visible = false
}
function removeConn(id: string) {
async function removeConn(id: string) {
const c = connections.value.find(x => x.id === id)
if (!await confirmDialog(t('settings.confirmDeleteConn', { name: c?.name || id }))) return
connections.value = connections.value.filter(c => c.id !== id)
persistConnections()
showToast(t('settings.toastDeleted'), 'info')
}
// ============================================================
@@ -412,11 +613,14 @@ function removeConn(id: string) {
// ============================================================
const settings = reactive({
theme: localStorage.getItem('df-theme') || 'dark',
language: localStorage.getItem('df-language') || 'zh-CN',
aiLanguage: localStorage.getItem('df-ai-language') || 'auto',
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),
})
function applyTheme(theme: string) {
@@ -433,29 +637,167 @@ function applyTheme(theme: string) {
watch(() => settings.theme, (val) => {
applyTheme(val)
localStorage.setItem('df-theme', val)
void appSettings.set('df-theme', val)
})
watch(() => settings.aiLanguage, (val) => {
localStorage.setItem('df-ai-language', 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)
})
// 同步 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)
}
watch(() => settings.language, (val) => {
localStorage.setItem('df-language', val)
void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换
i18n.global.locale.value = val as 'zh-CN' | 'en'
})
// ============================================================
// 知识库配置(提取 + 注入) — 走后端 IPC,非 localStorage
// ============================================================
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(() =>
aiProviders.value.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(() => {
applyTheme(settings.theme)
loadProviders()
loadConnections()
loadKnowledgeConfig()
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig()
})
// 组件卸载清 debounce timer,防 unmount 后旧 timer 仍 fire 发无效 IPC(timer 泄漏)
onUnmounted(() => {
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
})
</script>
<style scoped>
.settings { padding: 16px 20px 20px; }
/* ===== 顶部 Toast 提示 ===== */
.toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
padding: 8px 16px;
border-radius: var(--df-radius-sm);
font-size: 13px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* ===== 确认弹层 ===== */
.confirm-mask {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.confirm-box {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 20px;
min-width: 280px;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
.page-header {
display: flex;
justify-content: space-between;
@@ -527,6 +869,10 @@ onMounted(() => {
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;