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:
parent
4dee32195f
commit
ae4ecd645d
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user