修复: 协议层与体验缺陷六件
- temperature 按模型适配,仅收 1 的模型不再 400 - 流式 error 帧不再被 choices 必填整帧丢弃 - 只读缓存写后失效 - git diff 中文截断撞 UTF-8 边界 panic - 非视觉模型纯图片直接拒绝 - 全局快捷键补输入焦点守卫
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user