优化: 设置域体验完善

- 校验失败红框定位字段,保存反馈改持久化成功后
- 数值越界钳制,双提示队列化不再重叠
- 搜索防抖跳分类,托盘气泡文案进多语言
This commit is contained in:
lxy
2026-10-03 17:57:43 +08:00
parent f67e65633b
commit 8396849d04
19 changed files with 329 additions and 93 deletions
+26 -14
View File
@@ -29,16 +29,20 @@
</template>
</AppLayout>
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
<transition name="toast">
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
</transition>
<!-- 走查 P3-16:全局 toast 统一收进同一纵向堆叠容器。原错误 toast 与托盘提示气泡
各自 fixed 同位(bottom:24px 居中),两者同现时互相压盖、后弹者盖住先弹者。 -->
<div class="df-toast-stack">
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
<transition name="toast">
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
</transition>
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
<transition name="toast">
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
</transition>
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
<transition name="toast">
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
</transition>
</div>
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
@@ -63,6 +67,7 @@ import { useKnowledgeStore } from './stores/knowledge'
import { useProjectStore } from './stores/project'
import { useAppSettingsStore } from './stores/appSettings'
import i18n from './i18n'
import { t as ti18n } from './i18n/i18n-helpers'
import AiChat from './components/AiChat.vue'
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
import ApprovalDialog from './components/project/ApprovalDialog.vue'
@@ -227,7 +232,8 @@ onMounted(async () => {
_unlistenTrayHideNotice = await listen<{ running: number }>('tray-hide-notice', (e) => {
const running = e.payload?.running ?? 0
if (running <= 0) return
trayNoticeMsg.value = `${running} 个会话将继续在后台运行,托盘图标可随时唤起`
// 走查 P3-15:气泡文案硬编码中文 → 入 i18n(运行数经命名参数注入)
trayNoticeMsg.value = ti18n('common.trayNoticeRunning', { n: running })
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
trayNoticeTimer = setTimeout(() => { trayNoticeMsg.value = '' }, 3500)
})
@@ -313,16 +319,22 @@ onUnmounted(() => {
.page-enter-from { opacity: 0; transform: translateY(6px); }
.page-leave-to { opacity: 0; transform: translateY(-4px); }
/* ═══ 全局错误 toast ═══ */
.df-toast {
/* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══
容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。
子项保持 .df-toast 原形态(定宽自适应),定位字段从子项上移到容器。 */
.df-toast-stack {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 8px;
z-index: 9999; max-width: 90vw; pointer-events: none;
}
.df-toast {
background: var(--df-danger); color: #fff;
padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; z-index: 9999; max-width: 90vw;
font-size: 13px; max-width: 90vw;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
.df-toast--info { background: var(--df-accent); }
</style>
+18 -2
View File
@@ -289,6 +289,10 @@ const projectsStore = useProjectStore()
// 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。
watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit()
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
// 新会话同下标队列项上,随消息编辑态一并重置。
if (editingQueueIdx.value !== null) cancelEditQueued()
})
@@ -331,7 +335,14 @@ function onGlobalKeydown(e: KeyboardEvent) {
// Ctrl+R: 重新生成末条回复(替代浏览器刷新,Tauri 下 preventDefault 可靠)
if (ctrl && (e.key === 'r' || e.key === 'R')) {
e.preventDefault()
if (!store.state.streaming) store.regenerate()
// UX-P3: 末条非 assistant 等场景 regenerate() 会 reject(regenerateUnavailable),原样裸调
// 成 unhandled rejection 用户无感;对齐 MessageList.handleRetry 的 catch + toast。
if (!store.state.streaming) {
store.regenerate().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.regenerateFailed', { msg }), 'error')
})
}
return
}
// Ctrl+L: 清空当前对话(带 confirm;生成中跳过避免删流式中消息)
@@ -510,9 +521,14 @@ async function handleSendQueuedNow(idx: number) {
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
function backfillQueued(idx: number) {
async function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
if (chatInputRef.value?.hasUnsentInput()) {
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
}
const skill = item.skill
? store.state.skills.find(s => s.name === item.skill) ?? null
: null
+24 -13
View File
@@ -6,20 +6,21 @@
</div>
<div class="general-settings">
<SettingRow ref="rowTheme" :label="$t('settings.labelTheme')" :desc="$t('settings.descTheme')">
<select v-model="settings.theme" class="setting-select" @change="markSaved('theme')">
<!-- 走查 P3-11:不再 @change 即显「已保存」,反馈移到 persist 完成后(防假成功) -->
<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>
</SettingRow>
<SettingRow ref="rowLanguage" :label="$t('settings.labelLanguage')" :desc="$t('settings.descLanguage')">
<select v-model="settings.language" class="setting-select" @change="markSaved('language')">
<select v-model="settings.language" class="setting-select">
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowAiLanguage" :label="$t('settings.labelAiLanguage')" :desc="$t('settings.descAiLanguage')">
<select v-model="settings.aiLanguage" class="setting-select" @change="markSaved('aiLanguage')">
<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>
@@ -27,13 +28,13 @@
</SettingRow>
<SettingRow ref="rowToken" :label="$t('settings.labelShowTokenUsage')" :desc="$t('settings.descShowTokenUsage')">
<label class="toggle">
<input type="checkbox" v-model="settings.showTokenUsage" @change="markSaved('token')" />
<input type="checkbox" v-model="settings.showTokenUsage" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
<SettingRow ref="rowStreamingMd" :label="$t('settings.labelStreamingMd')" :desc="$t('settings.descStreamingMd')">
<label class="toggle">
<input type="checkbox" v-model="settings.streamingMd" @change="markSaved('streamingMd')" />
<input type="checkbox" v-model="settings.streamingMd" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
@@ -91,32 +92,42 @@ const rowLanguage = useTemplateRef<InstanceType<typeof SettingRow>>('rowLanguage
const rowAiLanguage = useTemplateRef<InstanceType<typeof SettingRow>>('rowAiLanguage')
const rowToken = useTemplateRef<InstanceType<typeof SettingRow>>('rowToken')
const rowStreamingMd = useTemplateRef<InstanceType<typeof SettingRow>>('rowStreamingMd')
function markSaved(key: 'theme' | 'language' | 'aiLanguage' | 'token' | 'streamingMd') {
const map = { theme: rowTheme.value, language: rowLanguage.value, aiLanguage: rowAiLanguage.value, token: rowToken.value, streamingMd: rowStreamingMd.value }
map[key]?.markSaved()
// 走查 P3-11 假成功族:原 @change 立即 markSaved,先于持久化完成,失败无人知晓。
// 改为 set 完成后才点亮「已保存」,失败点亮行内红字。注:appSettings.set 当前对落库
// 失败仅 console 内吞(stores/appSettings.ts 降级内存态设计),catch 实际不会触发;
// 待 store 层把失败上抛后本处失败通道即自动生效(登记项,见批次报告)。
async function persist(key: string, value: unknown, row: { value: { markSaved: () => void; markFailed: () => void } | null }) {
try {
await appSettings.set(key, value)
row.value?.markSaved()
} catch (e) {
console.error(`保存设置 ${key} 失败:`, e)
row.value?.markFailed()
}
}
watch(() => settings.theme, (val) => {
applyTheme(val)
void appSettings.set('df-theme', val)
void persist('df-theme', val, rowTheme)
})
watch(() => settings.aiLanguage, (val) => {
void appSettings.set('df-ai-language', val)
void persist('df-ai-language', val, rowAiLanguage)
})
watch(() => settings.showTokenUsage, (val) => {
void appSettings.set('df-show-token-usage', val)
void persist('df-show-token-usage', val, rowToken)
})
watch(() => settings.streamingMd, (val) => {
void appSettings.set('df-ai-streaming-md', val)
void persist('df-ai-streaming-md', val, rowStreamingMd)
})
watch(() => settings.language, (val) => {
void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换
locale.value = val as 'zh-CN' | 'en'
void persist('df-language', val, rowLanguage)
})
onMounted(() => {
+62 -26
View File
@@ -10,7 +10,8 @@
next-round
:label="$t('settings.labelGlobalConcurrency')"
:desc="$t('settings.descGlobalConcurrency')">
<input type="number" class="setting-number" min="1" style="width:80px"
<!-- 走查 P3-14:max=64 前端 sanity 上界(原生 min/max 只约束步进按钮,越界拦截在 JS clamp) -->
<input type="number" class="setting-number" min="1" max="64" style="width:80px"
v-model.number="settings.llmGlobalConcurrency"
@change="onConcurrencyChange" />
</SettingRow>
@@ -19,7 +20,7 @@
next-round
:label="$t('settings.labelPerConvConcurrency')"
:desc="$t('settings.descPerConvConcurrency')">
<input type="number" class="setting-number" min="1" style="width:80px"
<input type="number" class="setting-number" min="1" max="64" style="width:80px"
v-model.number="settings.llmPerConvConcurrency"
@change="onConcurrencyChange" />
</SettingRow>
@@ -29,7 +30,8 @@
:label="$t('settings.labelAgentMaxIterations')"
:desc="$t('settings.descAgentMaxIterations')">
<label class="unlimited-toggle">
<input type="checkbox" v-model="unlimited" @change="markSaved('iter')" />
<!-- 走查 P3-11:勾选反馈不再即时显,由 syncAgentMaxIterations 的 IPC 成功回调点亮 -->
<input type="checkbox" v-model="unlimited" />
<span>{{ $t('settings.labelUnlimited') }}</span>
</label>
<input type="number" class="setting-number" min="1" max="50" style="width:80px"
@@ -52,6 +54,7 @@
<script setup lang="ts">
import { reactive, watch, computed, onMounted, onUnmounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import SettingRow from './SettingRow.vue'
@@ -59,10 +62,16 @@ import SettingRow from './SettingRow.vue'
// ============================================================
// 性能域 — 本地设置(localStorage):LLM 并发 / 单对话并发 / Agent 轮次 / 流式重试
// 拆自 GeneralPanel,本 Section 仅持有性能相关 key + 各自 debounce IPC 同步
// 阶段5:SettingRow 封装行布局 + nextRound badge(后端快照锁定边界,下次对话生效);
// 4 项变更后调 markSaved 显"已保存"
// 阶段5:SettingRow 封装行布局 + nextRound badge(后端快照锁定边界,下次对话生效)
// 走查 P3-11:「已保存」改为 IPC 成功回调后点亮,失败点亮行内红字(防假成功)
// ============================================================
const appSettings = useAppSettingsStore()
const { t } = useI18n()
// 走查 P3-14:并发越界钳制需要上抛 warning toast,由壳(Settings.vue)的 showToast 消费
const emit = defineEmits<{
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
}>()
const settings = reactive({
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
@@ -73,7 +82,7 @@ const settings = reactive({
agentMaxRetries: appSettings.get<number>('df-ai-agent-max-retries', 3),
})
// 即时生效反馈:SettingRow refs
// 即时生效反馈:SettingRow refs(成功/失败两态,走查 P3-11)
const rowGlobalConcurrency = useTemplateRef<InstanceType<typeof SettingRow>>('rowGlobalConcurrency')
const rowPerConvConcurrency = useTemplateRef<InstanceType<typeof SettingRow>>('rowPerConvConcurrency')
const rowMaxIterations = useTemplateRef<InstanceType<typeof SettingRow>>('rowMaxIterations')
@@ -82,50 +91,73 @@ function markSaved(key: 'global' | 'perConv' | 'iter' | 'retry') {
const map = { global: rowGlobalConcurrency.value, perConv: rowPerConvConcurrency.value, iter: rowMaxIterations.value, retry: rowMaxRetries.value }
map[key]?.markSaved()
}
function markFailed(key: 'global' | 'perConv' | 'iter' | 'retry') {
const map = { global: rowGlobalConcurrency.value, perConv: rowPerConvConcurrency.value, iter: rowMaxIterations.value, retry: rowMaxRetries.value }
map[key]?.markFailed()
}
// SQLite 持久化(watch 触发):落库失败点亮行内红字。注:appSettings.set 当前对落库失败
// 仅 console 内吞(降级内存态设计,stores/appSettings.ts),catch 是 store 上抛错误后的
// 失败通道,今日实际不触发(登记项见批次报告);IPC 同步失败通道在下方 timer 内,是真实信号。
watch(() => settings.llmGlobalConcurrency, (v) => {
void appSettings.set('df-llm-global-concurrency', v)
appSettings.set('df-llm-global-concurrency', v).catch(() => markFailed('global'))
})
watch(() => settings.llmPerConvConcurrency, (v) => {
void appSettings.set('df-llm-per-conv-concurrency', v)
appSettings.set('df-llm-per-conv-concurrency', v).catch(() => markFailed('perConv'))
})
watch(() => settings.agentMaxIterations, (v) => {
void appSettings.set('df-ai-agent-max-iterations', v)
appSettings.set('df-ai-agent-max-iterations', v).catch(() => markFailed('iter'))
})
watch(() => settings.agentMaxRetries, (v) => {
void appSettings.set('df-ai-agent-max-retries', v)
appSettings.set('df-ai-agent-max-retries', v).catch(() => markFailed('retry'))
})
// onMounted 期间调 sync 不应触发"已保存"反馈(那是启动恢复,非用户改动)
let _userInteracting = false
// 走查 P3-14:并发上界 64 为前端 sanity 值(后端 ai_set_concurrency_config 仅 max(1) 无上界,
// 超大并发会让限流器形同虚设、放大上游限流/超时风险);越界时静默修正 + toast 告知修正结果
const MAX_CONCURRENCY = 64
/** 非法输入(NaN/清空)回退默认值,合法值 clamp [1,64] */
function clampConcurrency(v: number | undefined, fallback: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : fallback
return Math.min(MAX_CONCURRENCY, Math.max(1, n))
}
// 同步 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
// 下限 1,上界 64(前端 sanity);约束单对话并发不超过全局并发
let clamped = false
const g = clampConcurrency(settings.llmGlobalConcurrency, 3)
if (g !== settings.llmGlobalConcurrency) clamped = true
settings.llmGlobalConcurrency = g
let p = clampConcurrency(settings.llmPerConvConcurrency, 2)
if (p > g) { p = g; clamped = true }
settings.llmPerConvConcurrency = p
if (clamped && _userInteracting) {
emit('toast', t('settings.toastConcurrencyClamped', { max: MAX_CONCURRENCY }), 'warning')
}
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
// 走查 P3-11:interactive 在排程时捕获(debounce 回调执行时 _userInteracting 已复位 false)
const interactive = _userInteracting
_concurrencyTimer = setTimeout(async () => {
try {
await aiApi.setConcurrencyConfig(settings.llmGlobalConcurrency, settings.llmPerConvConcurrency)
await aiApi.setConcurrencyConfig(g, p)
// IPC 成功才显「已保存」,失败显行内红字(原实现先显成功、失败仅 console = 假成功)
if (interactive) { markSaved('global'); markSaved('perConv') }
} catch (e) {
console.error('同步 LLM 并发配置失败:', e)
if (interactive) { markFailed('global'); markFailed('perConv') }
}
}, 300)
if (_userInteracting) {
markSaved('global')
markSaved('perConv')
}
}
// wrapper:并发变更(@change 用户触发)同时反馈两项(global/perConv 任一变动都同步两项)
@@ -158,28 +190,32 @@ function syncAgentMaxIterations() {
settings.agentMaxIterations = Math.min(50, Math.max(1, settings.agentMaxIterations || 10))
}
if (_agentIterTimer) clearTimeout(_agentIterTimer)
const interactive = _userInteracting
_agentIterTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxIterations(settings.agentMaxIterations)
if (interactive) markSaved('iter')
} catch (e) {
console.error('同步 Agent 最大轮次失败:', e)
if (interactive) markFailed('iter')
}
}, 300)
if (_userInteracting) {
markSaved('iter')
}
}
// 「不限」勾选:agentMaxIterations===0 即不限(后端 0→usize::MAX,loop 靠 stop/收敛/审批退出)
const unlimited = computed({
get: () => settings.agentMaxIterations === 0,
set(v: boolean) {
// 走查 P3-11:勾选属用户交互,标记 interactive 让「已保存」等 IPC 成功后再显
// (原实现在模板 @change 即时 markSaved,先于持久化 = 假成功)
_userInteracting = true
if (v) {
settings.agentMaxIterations = 0
} else if (settings.agentMaxIterations === 0) {
settings.agentMaxIterations = 10
}
syncAgentMaxIterations()
_userInteracting = false
},
})
@@ -191,16 +227,16 @@ function syncAgentMaxRetries() {
// clamp 0-10,防越界
settings.agentMaxRetries = Math.min(10, Math.max(0, settings.agentMaxRetries ?? 3))
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
const interactive = _userInteracting
_agentRetryTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxRetries(settings.agentMaxRetries)
if (interactive) markSaved('retry')
} catch (e) {
console.error('同步流式重试次数失败:', e)
if (interactive) markFailed('retry')
}
}, 300)
if (_userInteracting) {
markSaved('retry')
}
}
onMounted(() => {
+58 -11
View File
@@ -67,7 +67,10 @@
<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')" />
<!-- 走查 P3-10:校验失败定位字段——红框 + @input 清错(示范级,仅本表单) -->
<input ref="inputName" v-model="providerForm.name" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.name }"
:placeholder="$t('settings.phProviderName')" @input="fieldErrors.name = false" />
</div>
<!-- 项 D:厂商预设下拉 — 选中自动填充 providerType/baseUrl/defaultModel(用户可改覆盖)。
新建态独立"快捷选择";编辑态回填后切预设仍可覆盖(等价快捷填充),不阻塞手改。
@@ -88,12 +91,16 @@
</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')" />
<input ref="inputBaseUrl" v-model="providerForm.baseUrl" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.baseUrl }"
:placeholder="$t('settings.phBaseUrl')" @input="fieldErrors.baseUrl = false" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
<!-- 走查 P3-9:type 恒 password 无法核对已粘贴密钥,加显隐切换 -->
<input v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'" :placeholder="$t('settings.phApiKey')" />
<input ref="inputApiKey" v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'"
:class="{ 'setting-input--invalid': fieldErrors.apiKey }"
:placeholder="$t('settings.phApiKey')" @input="fieldErrors.apiKey = false" />
<button type="button" class="btn-link" :title="$t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow')"
@click="showApiKey = !showApiKey">
{{ $t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow') }}
@@ -104,7 +111,9 @@
</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')" />
<input ref="inputDefaultModel" v-model="providerForm.defaultModel" class="setting-input"
:class="{ 'setting-input--invalid': fieldErrors.defaultModel }"
:placeholder="$t('settings.phDefaultModel')" @input="fieldErrors.defaultModel = false" />
<!-- 方向 A(轻量):defaultModel 仅作"建议起点"——预设 model id 易过时(DeepSeek chat 停用 /
Gemini 2.5→3.5 等),提示用户点「测试连接」拉真实模型列表覆盖,降低踩过时默认值的困惑。 -->
<div class="form-field-hint">{{ $t('settings.defaultModelHint') }}</div>
@@ -112,6 +121,7 @@
<!-- 供应商级采样温度覆盖:部分受限模型(如 Kimi 系)仅接受 temperature=1,留空走默认 0.7 -->
<div class="form-field">
<label class="form-label">{{ $t('settings.labelSamplingTemp') }}</label>
<!-- 走查 P3-14:原生 min/max 只约束步进按钮,手输越界由 saveProvider clamp + toast 提示 -->
<input type="number" v-model.number="providerForm.temperature" class="setting-input"
:placeholder="$t('settings.phSamplingTemp')" step="0.1" min="0" max="2" />
</div>
@@ -192,7 +202,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
@@ -228,6 +238,25 @@ const aiProviders = ref<AiProviderConfig[]>([])
// 密钥显隐切换(走查 P3-9):默认隐藏;重开表单时复位,防上一家 provider 的显钥状态延续
const showApiKey = ref(false)
// 走查 P3-10:校验失败只 toast 不定位字段——补字段级红框 + 滚动聚焦首个缺失字段。
// 仅保存必填四项(name/baseUrl/apiKey/defaultModel)做示范级实现;红框在对应输入框
// @input 时即清(用户开始修正就不该继续红着)。
const fieldErrors = reactive({ name: false, baseUrl: false, apiKey: false, defaultModel: false })
const inputName = useTemplateRef<HTMLInputElement>('inputName')
const inputBaseUrl = useTemplateRef<HTMLInputElement>('inputBaseUrl')
const inputApiKey = useTemplateRef<HTMLInputElement>('inputApiKey')
const inputDefaultModel = useTemplateRef<HTMLInputElement>('inputDefaultModel')
/** 聚焦首个红框字段(focus 默认行为即滚动到该元素,无需额外 scrollIntoView) */
function focusFirstError() {
const el = fieldErrors.name ? inputName.value
: fieldErrors.baseUrl ? inputBaseUrl.value
: fieldErrors.apiKey ? inputApiKey.value
: fieldErrors.defaultModel ? inputDefaultModel.value
: null
el?.focus()
}
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
const providerForm = reactive({
@@ -433,6 +462,7 @@ function openProviderForm(p?: AiProviderConfig) {
}
providerForm.fetching = false
providerForm.saveError = null // 重开表单清旧错误横幅
fieldErrors.name = fieldErrors.baseUrl = fieldErrors.apiKey = fieldErrors.defaultModel = false // 重开表单清字段红框
showApiKey.value = false // 密钥默认隐藏,不延续上次表单的显钥状态(防窥)
providerForm.visible = true
// L19/M32: 记初始快照(在 visible=true、字段回填完成后),isFormDirty 据此比对。
@@ -547,10 +577,28 @@ function classifySaveError(raw: string): { title: string; detail: string } {
async function saveProvider() {
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
const needKey = !providerForm.editId
if (!providerForm.name || !providerForm.baseUrl || (needKey && !providerForm.apiKey) || !providerForm.defaultModel) {
// 走查 P3-10:逐字段判定缺失,红框标出并滚动聚焦首个缺失字段(toast 保留,双通道反馈)
fieldErrors.name = !providerForm.name
fieldErrors.baseUrl = !providerForm.baseUrl
fieldErrors.apiKey = needKey && !providerForm.apiKey
fieldErrors.defaultModel = !providerForm.defaultModel
if (fieldErrors.name || fieldErrors.baseUrl || fieldErrors.apiKey || fieldErrors.defaultModel) {
emit('toast', t('settings.toastSaveIncomplete'), 'warning')
focusFirstError()
return
}
// 走查 P3-14:采样温度钳制 [0,2](原生 min/max 只约束步进按钮,手输 99 照存)。
// 越界有限值 → clamp + toast 告知修正值;NaN/清空残留 → 归一 null(走默认 0.7,同步清显示)
let temperatureOverride: number | undefined
if (typeof providerForm.temperature === 'number' && Number.isFinite(providerForm.temperature)) {
if (providerForm.temperature < 0 || providerForm.temperature > 2) {
providerForm.temperature = Math.min(2, Math.max(0, providerForm.temperature))
emit('toast', t('settings.toastTempClamped', { v: providerForm.temperature }), 'warning')
}
temperatureOverride = providerForm.temperature
} else {
providerForm.temperature = null
}
providerForm.saving = true
try {
await aiApi.saveProvider({
@@ -561,11 +609,7 @@ async function saveProvider() {
apiKey: providerForm.apiKey,
defaultModel: providerForm.defaultModel,
modelConfigs: providerForm.models,
// 采样温度非空且为有效数字才带(清空输入 v-model.number 可能残留 ''/NaN,不过滤会污染后端 config)
temperatureOverride:
typeof providerForm.temperature === 'number' && Number.isFinite(providerForm.temperature)
? providerForm.temperature
: undefined,
temperatureOverride,
})
// 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
providerForm.saveError = null
@@ -788,6 +832,9 @@ onMounted(loadProviders)
/* 输入/下拉在 form-field 内 flex 填满(基础样式走全局 settings.css .setting-input/.setting-select) */
.form-field .setting-input,
.form-field .setting-select { flex: 1; min-width: 0; }
/* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */
.setting-input--invalid { border-color: var(--df-danger) !important; }
.setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); }
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
+26 -10
View File
@@ -11,7 +11,11 @@
<div class="setting-control">
<slot />
<Transition name="saved-fade">
<span v-if="savedVisible" class="saved-hint">{{ $t('settings.savedHint') }}</span>
<!-- 走查 P3-11 假成功族:持久化/IPC 有成功/失败两态,反馈位同理补失败红字,
防止「已保存」承诺落空后用户无从察觉 -->
<span v-if="hintState !== 'none'" class="saved-hint" :class="{ 'saved-hint--fail': hintState === 'failed' }">
{{ hintState === 'saved' ? $t('settings.savedHint') : $t('settings.saveFailedHint') }}
</span>
</Transition>
</div>
</div>
@@ -21,9 +25,10 @@
import { shallowRef } from 'vue'
// ============================================================
// 统一设置行:label/desc 入 props,控件走默认 slot,变更后父级调 markSaved() 显反馈。
// 统一设置行:label/desc 入 props,控件走默认 slot,变更后父级调 markSaved() 显反馈,
// 持久化失败时调 markFailed() 显红字(走查 P3-11:只报成功不报失败 = 半个反馈位)。
// nextRound=true 显"下次对话生效" badge(性能域并发/轮次/重试白名单项)。
// savedVisible 用 shallowRef(布尔翻转无深响应需求);timer ref 持有供连续触发时清旧。
// hintState 用 shallowRef(字符串翻转无深响应需求);timer ref 持有供连续触发时清旧。
// ============================================================
defineProps<{
/** 设置项标题 */
@@ -34,30 +39,41 @@ defineProps<{
nextRound?: boolean
}>()
const savedVisible = shallowRef(false)
const hintState = shallowRef<'none' | 'saved' | 'failed'>('none')
const timer = shallowRef<ReturnType<typeof setTimeout> | null>(null)
/** 显"✓已保存"行内提示,2s 后消失。连续触发清旧 timer 防提前消失。 */
function markSaved() {
savedVisible.value = true
/** 点亮行内反馈提示,2s 后消失。连续触发清旧 timer 防提前消失。 */
function flashHint(state: 'saved' | 'failed') {
hintState.value = state
if (timer.value) clearTimeout(timer.value)
timer.value = setTimeout(() => {
savedVisible.value = false
hintState.value = 'none'
timer.value = null
}, 2000)
}
defineExpose({ markSaved })
/** 显"✓已保存"行内提示(持久化/IPC 成功后才调,防假成功) */
function markSaved() {
flashHint('saved')
}
/** 显"✗ 保存失败"行内提示(持久化/IPC 失败路径调用) */
function markFailed() {
flashHint('failed')
}
defineExpose({ markSaved, markFailed })
</script>
<style scoped>
/* 反馈位"✓已保存" — 行内紧贴控件右侧,2s 淡出 */
/* 反馈位"✓已保存" — 行内紧贴控件右侧,2s 淡出;失败态红字区分 */
.saved-hint {
margin-left: 10px;
font-size: 12px;
color: var(--df-accent);
white-space: nowrap;
}
.saved-hint--fail { color: var(--df-danger); }
/* badge"下次对话生效" — label 右侧小标 */
.next-round-badge {
display: inline-block;
+36 -14
View File
@@ -1,8 +1,9 @@
<template>
<!-- ═══ 设置左导航(master-detail 左栏)═══
阶段3 UX 重构:6 类导航(外观/AI模型/知识库/性能/安全/高级)+ emoji 图标。
阶段4 UX 重构:顶部搜索框 → 匹配 label/desc → 高亮匹配类 + 选中首匹配类
阶段4 UX 重构:顶部搜索框 → 匹配 label/desc → 即时高亮匹配类 + 防抖后跳首匹配类
+ emit scroll-target(父 Settings.vue 滚动到对应 setting-row)。
走查 P3-17:跳转防抖 350ms + 仅命中才跳,避免每键入强制切分类打断输入。
activeCategory 受控(双向),v-model 由 Settings.vue 绑定 + appSettings 持久化。
底部留导入导出占位(阶段6 接入)。 -->
<nav class="settings-nav" :class="{ 'is-horizontal': horizontal }">
@@ -52,7 +53,7 @@
// advanced 高级(autoExecuteMode/logLevel + 连接)
// ============================================================
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { SETTINGS_INDEX, type SettingsCategory, type SettingIndexEntry } from './searchIndex'
@@ -126,26 +127,47 @@ function isMatchedCategory(cat: SettingsCategory): boolean {
return matchedCategories.value.includes(cat)
}
/** 关键词变化 → 选中首匹配类 + 上抛首匹配项 key(触发右侧滚动定位)。
* 空关键词时不改 activeCategory(用户清空搜索应留在当前类),也不上抛。 */
/** 关键词变化 → (防抖后)选中首匹配类 + 上抛首匹配项 key(触发右侧滚动定位)。
* 空关键词时不改 activeCategory(用户清空搜索应留在当前类),也不上抛。
* 走查 P3-17:原实现每敲一键立即切分类+滚动,浏览 A 类打字被反复拽到 B 类打断输入。
* 改为 350ms 防抖——连续键入只更新左侧命中高亮(即时,computed),停顿后才跳转定位;
* 且仅当有命中时才跳(无命中保持原地,不打断)。 */
const JUMP_DEBOUNCE_MS = 350
let _jumpTimer: ReturnType<typeof setTimeout> | null = null
function cancelPendingJump() {
if (_jumpTimer) {
clearTimeout(_jumpTimer)
_jumpTimer = null
}
}
watch(
() => searchKeyword.value,
(kw) => {
const trimmed = kw.trim()
if (!trimmed) return
const first = matchedEntries.value[0]
if (!first) return
if (props.modelValue !== first.category) {
emit('update:modelValue', first.category)
}
// 分类切换后需等右侧 Section 挂载,父组件 watch scroll-target 时自带 nextTick
emit('scroll-target', first.key)
() => {
cancelPendingJump()
if (!searchKeyword.value.trim()) return
_jumpTimer = setTimeout(() => {
_jumpTimer = null
// 防抖窗口后关键词可能又变了,重读当前值;无命中不跳(仅命中时跳)
const trimmed = searchKeyword.value.trim()
if (!trimmed) return
const first = matchedEntries.value[0]
if (!first) return
if (props.modelValue !== first.category) {
emit('update:modelValue', first.category)
}
// 分类切换后需等右侧 Section 挂载,父组件 watch scroll-target 时自带 nextTick
emit('scroll-target', first.key)
}, JUMP_DEBOUNCE_MS)
},
)
// 卸载清防抖 timer,防 unmount 后旧 timer fire 时 emit 到已销毁组件
onUnmounted(cancelPendingJump)
defineExpose({
/** 清空搜索框(父组件切分类时可调用,但当前保留搜索状态便于用户继续搜) */
clearSearch() {
cancelPendingJump()
searchKeyword.value = ''
},
})
+13
View File
@@ -89,6 +89,19 @@ export default {
truncated: 'truncated',
failed: 'failed',
meta: '{rounds} rounds · {tokens} tokens',
// S3: finished card is clickable to open the read-only trace (hover hint)
viewHint: 'Click to view execution trace',
},
// S3 sub-agent trace overlay (opened from the finished card, read-only simple render,
// discarded on close)
agentTrace: {
title: 'Sub-agent Trace',
empty: 'No messages recorded',
loadFail: 'Failed to load sub-agent trace',
roleUser: 'User',
roleAssistant: 'Assistant',
roleTool: 'Tool result',
roleSystem: 'System',
},
},
}
+5
View File
@@ -292,5 +292,10 @@ export default {
sendErrRateLimit: 'Rate limit reached, please retry later (error: {msg})',
sendErrQuota: 'AI quota or balance insufficient, check your provider account (error: {msg})',
sendErrNetwork: 'Network error or timeout, check your connection and retry (error: {msg})',
// ── UX-P3 chat polish additions ──
editEmptyBlocked: 'Edit content is empty, submission cancelled',
skillNoMatchQuery: 'No skill matches "{q}", send blocked',
backfillOverwriteConfirm: 'The input box has an unsent draft; backfilling will overwrite it. Continue?',
},
}
+2
View File
@@ -24,5 +24,7 @@ export default {
perPage: 'Per page',
total: '{n} total',
},
// Close-to-tray notice bubble (App.vue consumes the tray-hide-notice event; moved from hardcoded Chinese)
trayNoticeRunning: '{n} conversation(s) keep running in the background — the tray icon brings the window back anytime',
},
}
+8
View File
@@ -17,7 +17,15 @@ export default {
// ===== Instant feedback (phase 5 SettingRow) =====
savedHint: '✓ Saved',
// False-success family: red inline feedback for persist/IPC failure
saveFailedHint: '✗ Save failed',
nextRoundBadge: 'Next message',
// Out-of-range clamp notices (concurrency 1-64 + per-conv ≤ global; temperature reports the corrected value)
toastConcurrencyClamped: 'Value out of range, corrected automatically (concurrency 1-{max}; per-conversation capped at global)',
toastTempClamped: 'Sampling temperature must be within 0-2, corrected to {v}',
// Prompt caching toggle save feedback (previously silent)
toastAnthropicCacheSaved: 'Prompt caching setting saved',
toastAnthropicCacheFail: 'Failed to save the prompt caching setting',
// ===== Connection mgmt (phase 7: experimental badge, not wired to AI yet) =====
experimentalBadge: 'Experimental · not wired to AI tools',
+2
View File
@@ -91,5 +91,7 @@ export default {
descSaveFailed: 'Failed to save description: {msg}',
saveDesc: 'Save',
cancelEdit: 'Cancel',
// S4 related conversations (reverse lookup of main sessions that ran task tools, click to jump)
relatedConversations: 'Conversations',
},
}
+12
View File
@@ -88,6 +88,18 @@ export default {
truncated: '已截断',
failed: '失败',
meta: '{rounds} 轮 · {tokens} tokens',
// S3:完成卡可点击查看只读轨迹(hover 提示)
viewHint: '点击查看执行轨迹',
},
// S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃)
agentTrace: {
title: '子代理执行轨迹',
empty: '暂无消息记录',
loadFail: '拉取子代理轨迹失败',
roleUser: '用户',
roleAssistant: '助手',
roleTool: '工具结果',
roleSystem: '系统',
},
},
}
+5
View File
@@ -293,5 +293,10 @@ export default {
sendErrRateLimit: '发送频率超限,请稍后重试(原始错误:{msg})',
sendErrQuota: 'AI 服务额度或余额不足,请检查 Provider 账户(原始错误:{msg})',
sendErrNetwork: '网络异常或请求超时,请检查网络后重试(原始错误:{msg})',
// ── UX-P3 对话域打磨追加 ──
editEmptyBlocked: '编辑内容为空,已取消提交',
skillNoMatchQuery: '未找到技能「{q}」,已阻止发送',
backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?',
},
}
+2
View File
@@ -24,5 +24,7 @@ export default {
perPage: '每页',
total: '共 {n} 条',
},
// 关窗后台运行气泡(App.vue 消费 tray-hide-notice 事件;走查 P3-15 由硬编码中文迁入)
trayNoticeRunning: '{n} 个会话将继续在后台运行,托盘图标可随时唤起',
},
}
+8
View File
@@ -17,7 +17,15 @@ export default {
// ===== 即时生效反馈(阶段5 SettingRow) =====
savedHint: '✓ 已保存',
// 走查 P3-11 假成功族:持久化/IPC 失败的红字反馈位
saveFailedHint: '✗ 保存失败',
nextRoundBadge: '下次对话生效',
// 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值)
toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)',
toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}',
// 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈)
toastAnthropicCacheSaved: 'Prompt 缓存设置已保存',
toastAnthropicCacheFail: 'Prompt 缓存设置保存失败',
// ===== 连接管理(阶段7:实验功能 badge 明示暂未接入 AI) =====
experimentalBadge: '实验功能·暂未接入 AI 工具',
+2
View File
@@ -91,5 +91,7 @@ export default {
descSaveFailed: '保存描述失败: {msg}',
saveDesc: '保存',
cancelEdit: '取消',
// S4 关联会话(审计表反查发起过任务工具的主会话,点击跳转)
relatedConversations: '关联会话',
},
}
View File
+20 -3
View File
@@ -44,7 +44,7 @@
<input
type="checkbox"
:checked="anthropicCache"
@change="anthropicCache = ($event.target as HTMLInputElement).checked"
@change="onAnthropicCacheChange(($event.target as HTMLInputElement).checked)"
/>
<span class="toggle-slider"></span>
</label>
@@ -57,8 +57,11 @@
:ai-providers="aiProviders"
/>
<!-- 性能:global/per-conv concurrency/max-iterations/max-retries -->
<PerformanceSection v-else-if="activeCategory === 'performance'" />
<!-- 性能:global/per-conv concurrency/max-iterations/max-retries(@toast 接走查 P3-14 越界修正提示) -->
<PerformanceSection
v-else-if="activeCategory === 'performance'"
@toast="showToast"
/>
<!-- 安全:allowed_dirs -->
<AllowedDirsPanel
@@ -153,6 +156,20 @@ onBeforeRouteLeave(async () => {
// 值落库为 JSON 布尔,后端 "true"/"1" 判定兼容)
const anthropicCache = appSettings.useSetting<boolean>('df-ai-anthropic-cache', false)
// 走查 P3-11:原开关切换零反馈,用户无从得知是否生效。改为 set 完成后 toast 反馈成败。
// 注:appSettings.set 当前落库失败仅 console 内吞(降级内存态),catch 是 store 上抛错误后
// 的失败通道,今日实际不触发(登记项见批次报告),成功反馈先补齐。
async function onAnthropicCacheChange(checked: boolean) {
anthropicCache.value = checked
try {
await appSettings.set('df-ai-anthropic-cache', checked)
showToast(ti18n('settings.toastAnthropicCacheSaved'), 'success')
} catch (e) {
console.error('保存 Prompt 缓存开关失败:', e)
showToast(ti18n('settings.toastAnthropicCacheFail'), 'error')
}
}
// 窄屏响应式:监听窗口宽度,nav 折叠为横向 tab(避免依赖仅 CSS 的 :has,
// 并让 SettingsNav 按横向模式渲染 list)
const isNarrowScreen = ref(false)