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> {
|
async function sendVisionQuery(): Promise<void> {
|
||||||
if (!pendingImage.value) return
|
if (!pendingImage.value) return
|
||||||
if (store.querying) return
|
if (store.querying) return
|
||||||
|
// 防守拍完照到点发送之间后端被切成纯文本模型的空窗(按钮此时已置灰,但图片还留在输入区)
|
||||||
|
if (!store.visionAvailable) {
|
||||||
|
message.warning('当前生成服务加载的不是多模态模型,无法识别图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const q = question.value.trim()
|
const q = question.value.trim()
|
||||||
const img = pendingImage.value
|
const img = pendingImage.value
|
||||||
@ -670,9 +675,14 @@ onUnmounted(() => {
|
|||||||
语音输出
|
语音输出
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Camera button -->
|
<!-- Camera button:显示与否看用户开关,能否点击看后端当前模型是否支持图像输入 -->
|
||||||
<div v-if="store.config?.vision.enabled" class="voice-btn-wrap">
|
<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">
|
<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"/>
|
<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"/>
|
<circle cx="12" cy="13" r="4"/>
|
||||||
@ -951,14 +961,20 @@ onUnmounted(() => {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: all .15s;
|
transition: all .15s;
|
||||||
}
|
}
|
||||||
.voice-btn:hover { background: var(--panel-3); }
|
.voice-btn:not(:disabled):hover { background: var(--panel-3); }
|
||||||
.voice-btn.on {
|
.voice-btn.on {
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
border-color: var(--green);
|
border-color: var(--green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 2px 0 #0a6238;
|
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 {
|
.voice-spinner {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@ -30,21 +30,41 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
|
|||||||
let genSse: EventSource | null = null
|
let genSse: EventSource | null = null
|
||||||
let embSse: 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() {
|
async function pollStatus() {
|
||||||
if (!needsService.value) return
|
if (!needsService.value) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${ER_BACKEND}/api/status`, { signal: AbortSignal.timeout(2000) })
|
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()
|
const data = await res.json()
|
||||||
erUp.value = true
|
erUp.value = true
|
||||||
erGenStatus.value = data.generate?.status || 'stopped'
|
erGenStatus.value = data.generate?.status || 'stopped'
|
||||||
erEmbedStatus.value = data.embed?.status || 'stopped'
|
erEmbedStatus.value = data.embed?.status || 'stopped'
|
||||||
store.serverState = { status: erGenStatus.value, logs: store.serverState.logs }
|
store.serverState = { status: erGenStatus.value, logs: store.serverState.logs }
|
||||||
store.embedServerState = { status: erEmbedStatus.value, logs: store.embedServerState.logs }
|
store.embedServerState = { status: erEmbedStatus.value, logs: store.embedServerState.logs }
|
||||||
|
store.visionAvailable = detectVisionCapability(data.generate ?? {})
|
||||||
} catch {
|
} catch {
|
||||||
erUp.value = false
|
erUp.value = false
|
||||||
erGenStatus.value = 'stopped'
|
erGenStatus.value = 'stopped'
|
||||||
erEmbedStatus.value = 'stopped'
|
erEmbedStatus.value = 'stopped'
|
||||||
|
store.visionAvailable = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,11 @@ export const useRagStore = defineStore('rag', () => {
|
|||||||
// 启动时硬件探测结果(null=尚未完成探测)
|
// 启动时硬件探测结果(null=尚未完成探测)
|
||||||
const detectedCard = ref<DetectedCard | null>(null)
|
const detectedCard = ref<DetectedCard | null>(null)
|
||||||
|
|
||||||
|
// 生成服务当前加载的模型是否支持图像输入(由 ServiceControl 的状态轮询写入)。
|
||||||
|
// 拍照提问要靠它决定按钮能不能点——后端换成纯文本模型时按钮置灰,
|
||||||
|
// 换回多模态模型后无需重启应用,下一轮轮询就会自动恢复可用。
|
||||||
|
const visionAvailable = ref(false)
|
||||||
|
|
||||||
// think 标签由 ChatPanel 负责渲染,此处直接透传
|
// think 标签由 ChatPanel 负责渲染,此处直接透传
|
||||||
const displayAnswer = computed(() => rawAnswer.value)
|
const displayAnswer = computed(() => rawAnswer.value)
|
||||||
|
|
||||||
@ -83,6 +88,7 @@ export const useRagStore = defineStore('rag', () => {
|
|||||||
embedServerLogs,
|
embedServerLogs,
|
||||||
npuStats,
|
npuStats,
|
||||||
detectedCard,
|
detectedCard,
|
||||||
|
visionAvailable,
|
||||||
refreshDbStatus,
|
refreshDbStatus,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user