feat: 拍照按钮按后端模型的视觉能力自动启用/置灰

后端加载的不是多模态模型时,拍照提问按钮置灰不可点;换回多模态模型后
下一轮状态轮询(5 秒)自动恢复可用,不需要重启应用。

判据放在 ServiceControl 已有的 /api/status 轮询里顺带算出来,写进 store
的 visionAvailable,这样复用现成的轮询节奏,模型切换能被自动感知。

detectVisionCapability 的判据顺序:模型加载时打印的 allow_image 最准确,
但实测发现 er_backend 的日志缓冲只留最近 200 条,服务跑一会儿这行就被
推理日志挤掉了,所以只在还能翻到时采信;翻不到时退回看启动命令里有没有
--mmproj——该字段不随日志滚动,是长期可靠的依据。没有采用 config 里的
modelType 字段:那是配置意图,用户改了配置但没重启服务时会与实际运行的
模型不符。

同时:按钮禁用态样式加强(原来只有 opacity .6,且 hover 规则没排除禁用态,
看着像还能点),补 not-allowed 光标和灰底灰字;sendVisionQuery 入口加一道
校验,防守拍完照到点发送之间后端被切走的空窗。

验证:真机停掉生成服务后拍照按钮图标灰度 80→184、另两个按钮 80→80 纹丝
不动,重启服务后自动回到 80,全程没有重启应用;detectVisionCapability
另用构造数据覆盖 7 个分支(含纯文本模型、allow_image=0、starting、字段
缺失)全部通过。测试用的停服务操作已确认恢复,启动命令与操作前逐字一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
This commit is contained in:
ssurui 2026-09-03 21:17:03 +08:00
parent 4dee32195f
commit ae4ecd645d
3 changed files with 47 additions and 5 deletions

View File

@ -246,6 +246,11 @@ async function sendQuery(): Promise<void> {
async function sendVisionQuery(): Promise<void> {
if (!pendingImage.value) return
if (store.querying) return
// 防守拍完照到点发送之间后端被切成纯文本模型的空窗(按钮此时已置灰,但图片还留在输入区)
if (!store.visionAvailable) {
message.warning('当前生成服务加载的不是多模态模型,无法识别图片')
return
}
const q = question.value.trim()
const img = pendingImage.value
@ -670,9 +675,14 @@ onUnmounted(() => {
语音输出
</button>
</div>
<!-- Camera button -->
<!-- Camera button:显示与否看用户开关,能否点击看后端当前模型是否支持图像输入 -->
<div v-if="store.config?.vision.enabled" class="voice-btn-wrap">
<button class="voice-btn" @click="cameraOpen = true">
<button
class="voice-btn"
:disabled="!store.visionAvailable"
:title="store.visionAvailable ? '拍照提问' : '当前生成服务加载的不是多模态模型,无法识别图片'"
@click="cameraOpen = true"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
@ -951,14 +961,20 @@ onUnmounted(() => {
font-family: inherit;
transition: all .15s;
}
.voice-btn:hover { background: var(--panel-3); }
.voice-btn:not(:disabled):hover { background: var(--panel-3); }
.voice-btn.on {
background: var(--green);
border-color: var(--green);
color: #fff;
box-shadow: 0 2px 0 #0a6238;
}
.voice-btn:disabled { opacity: .6; cursor: default; }
.voice-btn:disabled {
opacity: .5;
cursor: not-allowed;
background: var(--panel-3);
border-color: var(--line);
color: var(--ink-3);
}
.voice-spinner {
width: 22px;
height: 22px;

View File

@ -30,21 +30,41 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
let genSse: EventSource | null = null
let embSse: EventSource | null = null
/**
* 判断生成服务当前加载的模型能不能吃图片输入。
*
* 首选依据是模型加载后打印的 `allow_image = 1`,但 er_backend 的日志缓冲只留最近 200 条,
* 服务跑一会儿这行就会被推理日志挤掉(实测确认),所以只在还能翻到时采信;翻不到时退回
* 看启动命令里有没有 `--mmproj`——这个字段不随日志滚动,是长期可靠的依据。
*/
function detectVisionCapability(gen: {
status?: string
lastCommand?: string
logs?: Array<{ text?: string }>
}): boolean {
if (gen?.status !== 'running') return false
const logHit = gen.logs?.find((l) => l.text?.includes('allow_image'))
if (logHit?.text) return /allow_image\s*=\s*1/.test(logHit.text)
return !!gen.lastCommand?.includes('--mmproj')
}
async function pollStatus() {
if (!needsService.value) return
try {
const res = await fetch(`${ER_BACKEND}/api/status`, { signal: AbortSignal.timeout(2000) })
if (!res.ok) { erUp.value = false; return }
if (!res.ok) { erUp.value = false; store.visionAvailable = false; return }
const data = await res.json()
erUp.value = true
erGenStatus.value = data.generate?.status || 'stopped'
erEmbedStatus.value = data.embed?.status || 'stopped'
store.serverState = { status: erGenStatus.value, logs: store.serverState.logs }
store.embedServerState = { status: erEmbedStatus.value, logs: store.embedServerState.logs }
store.visionAvailable = detectVisionCapability(data.generate ?? {})
} catch {
erUp.value = false
erGenStatus.value = 'stopped'
erEmbedStatus.value = 'stopped'
store.visionAvailable = false
}
}

View File

@ -33,6 +33,11 @@ export const useRagStore = defineStore('rag', () => {
// 启动时硬件探测结果(null=尚未完成探测)
const detectedCard = ref<DetectedCard | null>(null)
// 生成服务当前加载的模型是否支持图像输入(由 ServiceControl 的状态轮询写入)。
// 拍照提问要靠它决定按钮能不能点——后端换成纯文本模型时按钮置灰,
// 换回多模态模型后无需重启应用,下一轮轮询就会自动恢复可用。
const visionAvailable = ref(false)
// think 标签由 ChatPanel 负责渲染,此处直接透传
const displayAnswer = computed(() => rawAnswer.value)
@ -83,6 +88,7 @@ export const useRagStore = defineStore('rag', () => {
embedServerLogs,
npuStats,
detectedCard,
visionAvailable,
refreshDbStatus,
loadConfig,
saveConfig,