修复: 协议层与体验缺陷六件

- temperature 按模型适配,仅收 1 的模型不再 400
- 流式 error 帧不再被 choices 必填整帧丢弃
- 只读缓存写后失效
- git diff 中文截断撞 UTF-8 边界 panic
- 非视觉模型纯图片直接拒绝
- 全局快捷键补输入焦点守卫
This commit is contained in:
lxy
2026-10-04 16:27:31 +08:00
parent 01d231c39d
commit 7aa0da32f2
28 changed files with 491 additions and 52 deletions
+4
View File
@@ -157,6 +157,10 @@ function initTheme() {
// UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
// 输入焦点守卫(2026-10-04 回归修复):输入控件内 Ctrl+I 让路(打字/组合键不切面板),
// 对齐 AiChat onGlobalKeydown Esc 分支的 closest target 守卫写法
const el = e.target as HTMLElement | null
if (el?.closest?.('input, textarea, [contenteditable]')) return
e.preventDefault()
aiStore.cyclePanelMode()
}
+31
View File
@@ -15,6 +15,8 @@
/>
<!-- F-260614-05 Phase 2b: 待发送图片预览 -->
<ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
<!-- B1: 当前模型不看图时贴图警示(发送仍允许,后端温和降级丢图;chip 持续可见防误判网络问题) -->
<div v-if="showImageIgnoreWarn" class="ai-img-warn-chip">{{ t('aiChat.imageIgnoredWarn') }}</div>
<textarea
ref="inputEl"
v-model="inputText"
@@ -304,6 +306,22 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
return textActive ? 'stop' : 'idle'
})
// ── B1: 非视觉模型贴图前置感知 ──
// 有效模型判定镜像 TopBar 口径(active provider → enabled 池 → override 命中优先,否则权重最高)。
// 池未知(未拉取/空)时保守视为支持(不误伤;后端 model_supports_vision 有 model_probe 兜底,
// 发图照旧温和降级)。发送仍允许(与现状兼容),仅以警示 chip 持续可见防「静默丢图」误判网络问题。
const currentModelSupportsVision = computed<boolean>(() => {
const p = store.state.providers.find(x => x.id === store.state.activeProvider)
|| store.state.providers.find(x => x.is_default)
|| store.state.providers[0]
if (!p?.model_configs?.length) return true
const models = p.model_configs.filter(m => m.enabled)
const chosen = (store.modelOverride.value ? models.find(m => m.model_id === store.modelOverride.value) : undefined) || models[0]
if (!chosen) return true
return (chosen.modalities ?? []).includes('vision')
})
const showImageIgnoreWarn = computed(() => pendingImages.value.length > 0 && !currentModelSupportsVision.value)
// ── 技能 `/` 联想 ──
const skillOpen = ref(false)
const skillIndex = ref(0)
@@ -849,6 +867,12 @@ async function handleSend() {
const skill = pendingSkill.value
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)
if (!text && !skill && pendingImages.value.length === 0) return
// B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮),
// 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状
if (!text && !skill && pendingImages.value.length > 0 && !currentModelSupportsVision.value) {
emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' })
return
}
// F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
// user 消息渲染多模态图,后端请求仍走 content 文本路径。
@@ -1181,4 +1205,11 @@ defineExpose({
}
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
.ai-img-warn-chip {
font-size: 10.5px;
color: var(--df-warning);
padding: 1px 4px;
}
</style>
+38 -2
View File
@@ -34,7 +34,8 @@
<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>
<!-- L19 对齐: 取消走 closeConnForm,脏表单二次确认后再关(防误弃改动) -->
<button class="btn btn-ghost btn-sm" @click="closeConnForm">{{ $t('common.cancel') }}</button>
</div>
<div class="form-grid">
<div class="form-field">
@@ -77,7 +78,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppSettingsStore } from '@/stores/appSettings'
@@ -120,6 +121,8 @@ const connForm = reactive({
port: DEFAULT_PORTS.mysql,
user: '',
password: '',
// L19 对齐: 表单打开/保存成功时的「已落库快照」JSON 串,用于 isConnDirty 比对(同 ProviderPanel 范式)
savedSnapshot: '' as string,
})
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
@@ -146,6 +149,34 @@ function typeLabel(type: string) {
return map[type] || type
}
// L19 对齐: 脏表单二次确认——关闭前与已落库快照比对,有改动先 confirmDialog 防误关丢配置
// (password 计入快照:输入了未保存的密钥同样算脏)
function connBusinessSnapshot(): string {
return JSON.stringify({
name: connForm.name,
type: connForm.type,
host: connForm.host,
port: connForm.port,
user: connForm.user,
password: connForm.password,
})
}
const isConnDirty = computed(() => {
if (!connForm.visible) return false
return connBusinessSnapshot() !== connForm.savedSnapshot
})
/** 关闭表单入口(模板「取消」用):脏表单二次确认后再关,防误弃改动 */
async function closeConnForm() {
if (isConnDirty.value) {
const ok = await props.confirmDialog(t('settings.confirmDiscardUnsaved'))
if (!ok) return // 用户取消关闭,继续编辑
}
connForm.visible = false
connForm.savedSnapshot = ''
}
function openConnForm(conn?: ConnRecord) {
if (conn) {
connForm.editId = conn.id
@@ -165,6 +196,8 @@ function openConnForm(conn?: ConnRecord) {
connForm.password = ''
}
connForm.visible = true
// 记初始快照(visible=true、字段回填完成后),isConnDirty 据此比对
connForm.savedSnapshot = connBusinessSnapshot()
}
/** 用户手动切换连接类型时,端口自动重置为该类型默认端口。
@@ -202,7 +235,10 @@ function saveConn() {
connections.value.push(record)
}
persistConnections()
// 成功明确反馈:关表单 + 清快照(刚落库无脏,直接关不走二次确认)+ 绿色「已保存」toast(对齐 ProviderPanel)
connForm.visible = false
connForm.savedSnapshot = ''
emit('toast', t('settings.toastSaved'), 'success')
} finally {
submitting.value = false
}
+23 -1
View File
@@ -521,6 +521,12 @@ async function fetchModels() {
apiKey: providerForm.apiKey,
providerId: providerForm.editId || null,
})
// 空列表守卫:拉到 0 个模型时保留现有模型表格(不覆盖),仅提示——
// 与后端 ai_fetch_models 空守卫同语义(空结果不清用户配置),防误清后顺手保存落库
if (models.length === 0) {
emit('toast', t('settings.toastFetchEmpty'), 'warning')
return
}
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
// enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重
@@ -599,6 +605,20 @@ async function saveProvider() {
} else {
providerForm.temperature = null
}
// 模型权重钳制 [0,100](同温度范式:原生 min/max 只约束步进按钮,手输越界照存)。
// 越界/小数 → clamp+取整;清空残留非数值(v-model.number 得 '')→ 归一 50(对齐负载池 NaN 兜底)。
// 多行聚合一次 toast,逐行报会刷屏。
let weightClamped = false
for (const m of providerForm.models) {
if (typeof m.weight === 'number' && Number.isFinite(m.weight)) {
const c = Math.min(100, Math.max(0, Math.trunc(m.weight)))
if (c !== m.weight) { m.weight = c; weightClamped = true }
} else {
m.weight = 50
weightClamped = true
}
}
if (weightClamped) emit('toast', t('settings.toastWeightClamped'), 'warning')
providerForm.saving = true
try {
await aiApi.saveProvider({
@@ -672,7 +692,9 @@ async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
const prev = p.enabled
p.enabled = enabled
try {
await aiApi.updateProviderPool(p.id, enabled, p.weight ?? 50)
// 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
const w = Math.min(100, Math.max(0, p.weight ?? 50))
await aiApi.updateProviderPool(p.id, enabled, w)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.enabled = prev // revert
+2
View File
@@ -23,6 +23,7 @@ export default {
// 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}',
toastWeightClamped: 'Model weight must be within 0-100; out-of-range values corrected (cleared inputs default to 50)',
// Prompt caching toggle save feedback (previously silent)
toastAnthropicCacheSaved: 'Prompt caching setting saved',
toastAnthropicCacheFail: 'Failed to save the prompt caching setting',
@@ -88,6 +89,7 @@ export default {
saveModels: 'Save model config',
modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them',
toastFetchOk: 'Fetched {count} models',
toastFetchEmpty: 'Fetched 0 models — existing config kept',
toastFetchFail: 'Fetch failed: {msg}',
fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}',
fetchFailedAuth: 'Authentication failed, check your API Key: {msg}',
+2
View File
@@ -23,6 +23,7 @@ export default {
// 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值)
toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)',
toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}',
toastWeightClamped: '模型权重须在 0-100 范围内,越界值已修正(清空视为 50)',
// 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈)
toastAnthropicCacheSaved: 'Prompt 缓存设置已保存',
toastAnthropicCacheFail: 'Prompt 缓存设置保存失败',
@@ -87,6 +88,7 @@ export default {
saveModels: '保存模型配置',
modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃',
toastFetchOk: '已拉取 {count} 个模型',
toastFetchEmpty: '拉取到 0 个模型,已保留现有配置',
toastFetchFail: '拉取模型失败:{msg}',
fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}',
fetchFailedAuth: '鉴权失败,请检查 API Key:{msg}',
+43 -8
View File
@@ -3,6 +3,7 @@
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
//! - 落库失败保留 pending 脏值待下次写入重试,经 lastWriteError 透出(见 set/flush)
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
//!
//! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 ——
@@ -24,6 +25,11 @@ const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
const pendingValues = new Map<string, unknown>()
/// 最近一次落库失败(key + 错误),成功落库同一 key 后清 null。
/// store 层无组件上下文,不直接弹 toast,对齐 knowledge.ts 的 state.error 范式,
/// 上层 watch 此状态做行内红字/toast 通报。
const lastWriteError = ref<{ key: string; error: unknown } | null>(null)
/** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */
async function loadAll(): Promise<void> {
const all = await settingsApi.getAll()
@@ -41,6 +47,9 @@ function get<T>(key: string, defaultValue: T): T {
/**
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
*
* promise 契约:resolve 仅代表"已调度落库",不代表已落库;本 promise 永不
* reject(调用方 void/await 均安全),落库成败经 lastWriteError 透出。
*/
async function set(key: string, value: unknown): Promise<void> {
cache[key] = value
@@ -51,18 +60,35 @@ async function set(key: string, value: unknown): Promise<void> {
const timer = setTimeout(() => {
pendingTimers.delete(key)
const latest = pendingValues.get(key)
pendingValues.delete(key)
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
settingsApi
.set(key, JSON.stringify(latest))
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
void flush(key)
}, SET_DEBOUNCE_MS)
pendingTimers.set(key, timer)
}
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除 */
/**
* 把 pending 中该 key 的最新值落库。失败时保留 pending 脏值不清除,
* 下次该 key 任何 set 会合并进同一队列自然重试;cache 保持乐观值不回跳
* (避免落库失败把用户已输入的视图打回去),失败经 lastWriteError 与
* console.error 双通道透出,状态不再"假成功"。
*/
async function flush(key: string): Promise<void> {
const latest = pendingValues.get(key)
if (latest === undefined) return
try {
await settingsApi.set(key, JSON.stringify(latest))
// 成功才清 pending;若落库期间又有新写合并进来,保留新值交由新定时器落库
if (Object.is(pendingValues.get(key), latest)) pendingValues.delete(key)
if (lastWriteError.value?.key === key) lastWriteError.value = null
} catch (e) {
console.error(`[appSettings] 写入 ${key} 失败,已标记待重试(下次改动该 key 时重试):`, e)
lastWriteError.value = { key, error: e }
}
}
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除;删除失败恢复内存态后原样上抛 */
async function remove(key: string): Promise<void> {
const prevCache = cache[key]
const prevPending = pendingValues.get(key)
delete cache[key]
pendingValues.delete(key)
const t = pendingTimers.get(key)
@@ -70,7 +96,15 @@ async function remove(key: string): Promise<void> {
clearTimeout(t)
pendingTimers.delete(key)
}
await settingsApi.delete(key)
try {
await settingsApi.delete(key)
} catch (e) {
// 删除失败:磁盘仍有该值,恢复内存态保持两端一致,避免重启后该 key 幽灵复活而视图已丢
if (prevCache !== undefined) cache[key] = prevCache
if (prevPending !== undefined) pendingValues.set(key, prevPending)
lastWriteError.value = { key, error: e }
throw e
}
}
/**
@@ -149,6 +183,7 @@ function parse(raw: string): unknown {
export function useAppSettingsStore() {
return {
cache,
lastWriteError,
loadAll,
get,
set,