feat(chat,settings): multimodal sidecar configuration + routing visibility (#87)

This commit is contained in:
matevip 2026-05-09 16:41:36 +08:00
parent c2aecf18ef
commit 932f3de402
10 changed files with 1113 additions and 2 deletions

View File

@ -101,6 +101,8 @@ export const agentApi = {
chat: (id: string | number, data: any) => http.post(`/agents/${id}/chat`, data),
execute: (id: string | number, data: any) => http.post(`/agents/${id}/execute`, data),
getState: (id: string | number) => http.get(`/agents/${id}/state`),
/** Lightweight capability snapshot used by the chat console attachment hint. */
getCapabilities: (id: string | number) => http.get(`/agents/${id}/capabilities`),
}
// ==================== Templates ====================
@ -444,8 +446,8 @@ export const modelApi = {
disableProvider: (providerId: string) => http.post(`/models/${providerId}/disable`),
// ==================== Embedding Model (RFC Embedding UI) ====================
listByType: (modelType: 'chat' | 'embedding') =>
http.get('/models/by-type', { params: { modelType } }),
listByType: (modelType: 'chat' | 'embedding', modality?: 'vision' | 'video' | 'audio') =>
http.get('/models/by-type', { params: { modelType, modality } }),
testEmbedding: (modelId: string | number) =>
http.post(`/models/embedding/${modelId}/test`),
getDefaultEmbedding: () => http.get('/models/embedding/default'),

View File

@ -343,6 +343,12 @@
class="action-model"
:title="replyModelTitle"
>{{ replyModel }}</span>
<!-- Multimodal sidecar routing badge (assistant only, when sidecar fired) -->
<span
v-if="role === 'assistant' && routingBadge"
class="action-routing"
:title="routingBadge.tooltip"
>🔀 {{ routingBadge.label }}</span>
<!-- 时间戳inline -->
<span class="action-time">{{ formattedTime }}</span>
</div>
@ -726,6 +732,30 @@ const replyModelTitle = computed(() => {
return provider ? `${base} (${provider})` : base
})
// Multimodal routing badge: rendered only when a sidecar actually fired this
// turn (strategy=sidecar, sidecarModel populated). Skipped for the legacy
// "primary handled it natively" case so non-routed turns stay clean.
const routingBadge = computed(() => {
const r = props.message.metadata?.routing
if (!r || r.strategy !== 'sidecar' || !r.sidecarModel) return null
// Count routed attachments by required modality. We summarize as "1 image"
// / "2 images" rather than naming each file to keep the chip compact.
const required = r.requiredModalities || []
const kind = required.includes('VISION')
? t('chat.routing.kind.image')
: required.includes('VIDEO')
? t('chat.routing.kind.video')
: t('chat.routing.kind.media')
const label = `${r.sidecarModel} (${kind})`
const tooltip = t('chat.routing.tooltip', {
primary: replyModel.value || '?',
sidecar: r.sidecarModel,
sidecarProvider: r.sidecarProvider || '',
kind,
})
return { label, tooltip }
})
const formatFileSize = (size: number) => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
@ -1503,6 +1533,19 @@ watch(isGenerating, (generating) => {
white-space: nowrap;
}
.action-routing {
font-size: 11px;
color: var(--mc-primary, #d96d46);
margin-left: 4px;
padding: 1px 6px;
border-radius: 4px;
background: var(--mc-primary-bg, rgba(217, 109, 70, 0.1));
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
user-select: text;
white-space: nowrap;
font-weight: 500;
}
/* ==================== 主内容区域 ==================== */
.msg-content {
position: relative;

View File

@ -0,0 +1,160 @@
<template>
<div v-if="hint" class="routing-hint" :class="`routing-hint--${hint.tone}`">
<span class="routing-hint__icon" aria-hidden="true">
<svg v-if="hint.tone === 'info'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</span>
<span class="routing-hint__text">{{ hint.text }}</span>
<button
v-if="hint.actionLabel"
type="button"
class="routing-hint__action"
@click="onAction"
>{{ hint.actionLabel }}</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import type { AgentCapabilities, ChatAttachment } from '@/types'
const props = defineProps<{
/** Pending attachments in the input box. Hint reacts to image/video parts. */
attachments: ChatAttachment[]
/** Capability snapshot loaded for the current agent. Null while in flight. */
capabilities: AgentCapabilities | null
}>()
const { t } = useI18n()
const router = useRouter()
interface Hint {
tone: 'info' | 'warn'
text: string
actionLabel?: string
actionHref?: string
}
const hint = computed<Hint | null>(() => {
const caps = props.capabilities
if (!caps) return null
const hasImage = props.attachments.some(a => (a.contentType || '').startsWith('image/'))
const hasVideo = props.attachments.some(a => (a.contentType || '').startsWith('video/'))
if (!hasImage && !hasVideo) return null
const supportsVision = caps.modalities.includes('VISION')
const supportsVideo = caps.modalities.includes('VIDEO')
const sidecarVision = caps.defaultVisionModelLabel
const sidecarVideo = caps.defaultVideoModelLabel
// Image attachment branches
if (hasImage && !supportsVision) {
if (sidecarVision) {
return {
tone: 'info',
text: t('chat.routing.hint.willRoute', {
kind: t('chat.routing.kind.image'),
primary: caps.modelName,
sidecar: sidecarVision,
}),
}
}
return {
tone: 'warn',
text: t('chat.routing.hint.notConfigured', {
kind: t('chat.routing.kind.image'),
}),
actionLabel: t('chat.routing.hint.action.gotoSettings'),
actionHref: '/settings/models',
}
}
// Video attachment branches v1 has no sidecar, only point user at switching the primary model
if (hasVideo && !supportsVideo) {
return {
tone: 'warn',
text: sidecarVideo
? t('chat.routing.hint.videoReserved', { sidecar: sidecarVideo })
: t('chat.routing.hint.notConfigured', { kind: t('chat.routing.kind.video') }),
actionLabel: t('chat.routing.hint.action.gotoSettings'),
actionHref: '/settings/models',
}
}
return null
})
function onAction() {
if (hint.value?.actionHref) router.push(hint.value.actionHref)
}
</script>
<style scoped>
.routing-hint {
margin: 6px 0;
padding: 8px 12px;
border-radius: 6px;
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
line-height: 1.5;
border: 1px solid transparent;
}
.routing-hint--info {
background: var(--mc-primary-bg, rgba(217, 109, 70, 0.08));
color: var(--mc-text-primary);
border-color: color-mix(in srgb, var(--mc-primary, #d96d46) 25%, transparent);
}
.routing-hint--warn {
background: var(--mc-warning-bg, rgba(245, 158, 11, 0.10));
color: var(--mc-text-primary);
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
}
.routing-hint__icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--mc-primary, #d96d46);
}
.routing-hint--warn .routing-hint__icon {
color: #f59e0b;
}
.routing-hint__text {
flex: 1;
min-width: 0;
}
.routing-hint__action {
flex-shrink: 0;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid currentColor;
background: transparent;
color: var(--mc-primary, #d96d46);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.routing-hint--warn .routing-hint__action {
color: #b45309;
}
.routing-hint__action:hover {
background: var(--mc-primary, #d96d46);
color: #fff;
}
.routing-hint--warn .routing-hint__action:hover {
background: #f59e0b;
color: #fff;
}
</style>

View File

@ -0,0 +1,408 @@
<template>
<div ref="triggerRef" class="model-picker">
<button
type="button"
class="model-picker__trigger"
:class="{ 'is-open': open, 'is-empty': !selectedOption, 'is-disabled': disabled }"
:disabled="disabled"
@click="toggle"
>
<span class="model-picker__trigger-text">
<template v-if="selectedOption">
<span class="model-picker__provider">{{ selectedOption.provider }}</span>
<span class="model-picker__sep">/</span>
<span class="model-picker__model-name">{{ selectedOption.modelName }}</span>
</template>
<template v-else>{{ placeholder }}</template>
</span>
<span v-if="clearable && selectedOption && !disabled" class="model-picker__clear" @click.stop="clear">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</span>
<svg class="model-picker__caret" :class="{ open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="model-picker__backdrop" @click="open = false"></div>
</Transition>
<Transition name="picker-pop">
<div
v-if="open"
ref="popRef"
class="model-picker__pop"
:style="popStyle"
role="listbox"
>
<div v-if="searchable && totalCount > 5" class="model-picker__search">
<svg class="model-picker__search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchRef"
v-model="query"
class="model-picker__search-input"
:placeholder="searchPlaceholder || t('common.search')"
@keydown.esc.stop="open = false"
/>
</div>
<div class="model-picker__list">
<template v-for="group in filteredGroups" :key="group.provider">
<div class="model-picker__group-header">{{ group.provider }}</div>
<button
v-for="m in group.items"
:key="m.id"
type="button"
class="model-picker__item"
:class="{ active: String(m.id) === String(modelValue) }"
role="option"
:aria-selected="String(m.id) === String(modelValue)"
@click="select(m)"
>
<span class="model-picker__item-name">{{ m.modelName }}</span>
<span v-if="m.name && m.name !== m.modelName" class="model-picker__item-alias">{{ m.name }}</span>
<svg
v-if="String(m.id) === String(modelValue)"
class="model-picker__check"
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
>
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
</template>
<div v-if="filteredGroups.length === 0" class="model-picker__empty">
{{ query.trim() ? t('common.noResults') : (emptyText || t('common.noOptions')) }}
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
interface ModelOption {
id: string | number
name?: string
provider: string
modelName: string
}
const props = withDefaults(defineProps<{
/** v-model value: the selected model id (string | number | null). */
modelValue: string | number | null
/** Available options. Grouped internally by `provider`. */
models: ModelOption[]
/** Trigger placeholder shown when no model is selected. */
placeholder?: string
/** Text inside the popover when no models match the search / there are no models at all. */
emptyText?: string
/** Search box placeholder. Defaults to common.search. */
searchPlaceholder?: string
/** Show search box when total options exceed 5. */
searchable?: boolean
/** Show inline ✕ button on the trigger to clear the current selection. */
clearable?: boolean
/** Render trigger in disabled state (no popover, no clear). */
disabled?: boolean
}>(), {
searchable: true,
clearable: true,
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string | number | null]
change: [value: string | number | null, option: ModelOption | null]
}>()
const { t } = useI18n()
const open = ref(false)
const query = ref('')
const triggerRef = ref<HTMLElement | null>(null)
const popRef = ref<HTMLElement | null>(null)
const searchRef = ref<HTMLInputElement | null>(null)
const popStyle = ref<Record<string, string>>({})
const selectedOption = computed<ModelOption | null>(() => {
if (props.modelValue === null || props.modelValue === undefined || props.modelValue === '') return null
return props.models.find(m => String(m.id) === String(props.modelValue)) || null
})
const totalCount = computed(() => props.models.length)
interface ProviderGroup { provider: string; items: ModelOption[] }
const filteredGroups = computed<ProviderGroup[]>(() => {
const q = query.value.trim().toLowerCase()
const groups = new Map<string, ModelOption[]>()
for (const m of props.models) {
if (q) {
const hay = (m.provider + ' ' + m.modelName + ' ' + (m.name || '')).toLowerCase()
if (!hay.includes(q)) continue
}
if (!groups.has(m.provider)) groups.set(m.provider, [])
groups.get(m.provider)!.push(m)
}
return [...groups.entries()].map(([provider, items]) => ({ provider, items }))
})
function toggle() {
if (props.disabled) return
open.value = !open.value
}
function select(model: ModelOption) {
emit('update:modelValue', model.id)
emit('change', model.id, model)
open.value = false
}
function clear() {
if (props.disabled) return
emit('update:modelValue', null)
emit('change', null, null)
}
function position() {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
// Anchor below the trigger; if there isn't enough room, flip above.
const popHeight = 320
const spaceBelow = window.innerHeight - r.bottom
const flip = spaceBelow < popHeight && r.top > popHeight
popStyle.value = {
position: 'fixed',
left: `${r.left}px`,
top: flip ? `${r.top - popHeight - 4}px` : `${r.bottom + 4}px`,
width: `${Math.max(r.width, 320)}px`,
zIndex: '2200',
}
}
watch(open, (val) => {
if (val) {
query.value = ''
nextTick(() => {
position()
searchRef.value?.focus()
})
window.addEventListener('resize', position)
window.addEventListener('scroll', position, true)
} else {
window.removeEventListener('resize', position)
window.removeEventListener('scroll', position, true)
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', position)
window.removeEventListener('scroll', position, true)
})
</script>
<style scoped>
.model-picker {
position: relative;
width: 100%;
}
.model-picker__trigger {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border-radius: 4px;
cursor: pointer;
font-size: 13px;
text-align: left;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.model-picker__trigger:hover:not(.is-disabled):not(.is-open) {
border-color: color-mix(in srgb, var(--mc-primary) 50%, var(--mc-border));
}
.model-picker__trigger.is-open {
border-color: var(--mc-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--mc-primary) 15%, transparent);
}
.model-picker__trigger.is-disabled {
opacity: 0.55;
cursor: not-allowed;
}
.model-picker__trigger.is-empty .model-picker__trigger-text {
color: var(--mc-text-tertiary);
font-family: inherit;
}
.model-picker__trigger-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
}
.model-picker__provider {
color: var(--mc-text-secondary);
}
.model-picker__sep {
margin: 0 4px;
color: var(--mc-text-tertiary);
}
.model-picker__model-name {
color: var(--mc-text-primary);
font-weight: 500;
}
.model-picker__clear {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
color: var(--mc-text-tertiary);
transition: background 120ms ease, color 120ms ease;
}
.model-picker__clear:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.model-picker__caret {
flex-shrink: 0;
color: var(--mc-text-tertiary);
transition: transform 120ms ease;
}
.model-picker__caret.open {
transform: rotate(180deg);
color: var(--mc-primary);
}
.model-picker__backdrop {
position: fixed;
inset: 0;
z-index: 2199;
background: transparent;
}
.model-picker__pop {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 6px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.15);
display: flex;
flex-direction: column;
max-height: 320px;
overflow: hidden;
}
.model-picker__search {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--mc-border);
background: var(--mc-bg-sunken);
}
.model-picker__search-icon {
color: var(--mc-text-tertiary);
flex-shrink: 0;
}
.model-picker__search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: var(--mc-text-primary);
}
.model-picker__search-input::placeholder {
color: var(--mc-text-tertiary);
}
.model-picker__list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.model-picker__group-header {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--mc-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.model-picker__item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border: none;
background: transparent;
color: var(--mc-text-primary);
cursor: pointer;
font-size: 13px;
text-align: left;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
transition: background 80ms ease;
}
.model-picker__item:hover {
background: var(--mc-bg-sunken);
}
.model-picker__item.active {
background: color-mix(in srgb, var(--mc-primary) 8%, transparent);
color: var(--mc-primary);
}
.model-picker__item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-picker__item-alias {
font-size: 11px;
color: var(--mc-text-tertiary);
font-family: inherit;
}
.model-picker__check {
flex-shrink: 0;
color: var(--mc-primary);
}
.model-picker__empty {
padding: 24px 12px;
text-align: center;
font-size: 12px;
color: var(--mc-text-tertiary);
}
/* Transitions */
.fade-enter-active, .fade-leave-active { transition: opacity 120ms ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.picker-pop-enter-active, .picker-pop-leave-active { transition: opacity 140ms ease, transform 140ms ease; }
.picker-pop-enter-from, .picker-pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>

View File

@ -4,6 +4,8 @@ export default {
},
common: {
save: 'Save',
saving: 'Saving...',
saved: 'Saved',
cancel: 'Cancel',
reset: 'Reset',
edit: 'Edit',
@ -22,6 +24,8 @@ export default {
close: 'Close',
add: 'Add',
search: 'Search',
noResults: 'No matches',
noOptions: 'No options',
clear: 'Clear',
expandSidebar: 'Expand sidebar',
collapseSidebar: 'Collapse sidebar',
@ -149,6 +153,22 @@ export default {
copied: 'Copied',
regenerate: 'Regenerate',
replyModel: 'Reply model: {model}',
routing: {
kind: {
image: 'image',
video: 'video',
media: 'media',
},
tooltip: 'This turn was answered by {primary}; image attachments were captioned via {sidecar} ({sidecarProvider}) before reaching the primary model.',
hint: {
willRoute: 'Detected {kind} attachment. The current model "{primary}" does not support {kind} input — it will be captioned by "{sidecar}" first (sidecar mode, primary chat unchanged).',
notConfigured: 'Detected {kind} attachment. The current model does not support {kind} input and no sidecar model has been configured.',
videoReserved: 'Detected video attachment. Video sidecar is reserved (pre-configured "{sidecar}") but not yet wired in this version. Switch to a video-capable primary model instead.',
action: {
gotoSettings: 'Configure',
},
},
},
ttsPlay: 'Read Aloud',
ttsStop: 'Stop Reading',
conversations: 'Conversations',
@ -506,6 +526,25 @@ export default {
about: 'About',
advanced: 'Advanced',
},
models: {
sidecar: {
title: 'Multimodal sidecar',
hint: "When the primary model can't handle images or video, the sidecar model captions the attachment to text first.",
notConfigured: 'Not configured (skip attachment)',
idle: 'Off',
reserved: 'Reserved',
vision: {
label: 'Vision sidecar model',
desc: 'Invoked once per uploaded image to produce a structured description. The primary chat model is unchanged.',
empty: 'No vision-capable model is enabled yet. Add one under "Cloud Models" above.',
},
video: {
label: 'Video sidecar model (reserved)',
desc: 'Reserved for a future iteration: when the user uploads a video, this model will sample frames and describe them. Not yet wired in v1.',
empty: 'No video-capable model is enabled yet.',
},
},
},
featureFlags: {
title: 'Feature Flags',
description: 'Runtime-toggleable feature switches. Edits apply immediately on the writing instance and propagate to peers within one cache refresh tick (≈30 s); no server restart required.',

View File

@ -4,6 +4,8 @@ export default {
},
common: {
save: '保存',
saving: '保存中...',
saved: '已保存',
cancel: '取消',
reset: '重置',
edit: '编辑',
@ -22,6 +24,8 @@ export default {
close: '关闭',
add: '添加',
search: '搜索',
noResults: '没有匹配项',
noOptions: '暂无可选项',
clear: '清除',
expandSidebar: '展开侧边栏',
collapseSidebar: '折叠侧边栏',
@ -149,6 +153,22 @@ export default {
copied: '已复制',
regenerate: '重新生成',
replyModel: '本条回复模型: {model}',
routing: {
kind: {
image: '图片',
video: '视频',
media: '附件',
},
tooltip: '本轮对话由「{primary}」处理;图片附件先经「{sidecar}」({sidecarProvider})转成文字后再交给主模型。',
hint: {
willRoute: '检测到{kind}附件。当前 Agent 模型「{primary}」不支持{kind}输入,将自动调用「{sidecar}」识别(旁路模式,不影响主模型)。',
notConfigured: '检测到{kind}附件。当前 Agent 模型不支持{kind}输入,且尚未配置旁路模型。',
videoReserved: '检测到视频附件。当前版本暂未接入视频旁路(已预留「{sidecar}」),建议切换到具备视频能力的多模态主模型。',
action: {
gotoSettings: '前往配置',
},
},
},
ttsPlay: '朗读',
ttsStop: '停止朗读',
conversations: '会话列表',
@ -392,6 +412,25 @@ export default {
about: '关于',
advanced: '高级',
},
models: {
sidecar: {
title: '多模态旁路',
hint: '当主模型不支持图片/视频时,自动用下面配置的模型先把附件转成文字,再交给主模型回答。',
notConfigured: '未配置(不路由附件)',
idle: '未启用',
reserved: '预留',
vision: {
label: '视觉旁路模型',
desc: '用户上传图片时调用一次,把图片转成结构化描述。主对话模型不变。',
empty: '尚未发现支持图片输入的模型。请到上方"云端模型"中先添加一个视觉模型。',
},
video: {
label: '视频旁路模型(预留)',
desc: '用于未来版本:当用户上传视频时,由该模型负责拆帧和描述。当前版本暂不接入路由。',
empty: '尚未发现支持视频输入的模型。',
},
},
},
featureFlags: {
title: '功能开关',
description: '运行时可切换的功能开关。修改后立即在所有节点生效(每实例 30 秒内同步缓存);不需要重启服务。',

View File

@ -200,6 +200,36 @@ export interface MessageMetadata {
durationMs: number
timestamp: number
}>
/**
* Multimodal sidecar routing snapshot for this turn written by the backend
* when the user uploaded an image / video the primary model couldn't handle
* natively. The chat bubble renders a "primary 🔀 sidecar" badge from this.
*/
routing?: RoutingMeta
}
export interface RoutingMeta {
/** "none" | "sidecar" | "native" — lowercased on the wire to keep the JSON small. */
strategy: 'none' | 'sidecar' | 'native'
sidecarModelId?: number
sidecarModel?: string
sidecarProvider?: string
/** Modality names like ["VISION"] / ["VIDEO"] / ... — uppercase to match the backend enum. */
requiredModalities?: string[]
primaryMissing?: string[]
skipped?: Array<{ type: string; fileName?: string; reason: string }>
}
export interface AgentCapabilities {
agentId: number
modelName: string
providerId: string
/** Modality enum values: TEXT / VISION / VIDEO / AUDIO. */
modalities: string[]
defaultVisionModelId?: number | null
defaultVisionModelLabel?: string | null
defaultVideoModelId?: number | null
defaultVideoModelLabel?: string | null
}
export interface MessageContentPart {

View File

@ -286,6 +286,13 @@
:lifecycle-stage="lifecycleStage"
/>
<!-- Multimodal routing hint: shown when pending attachments require a
modality the primary model lacks. -->
<MultimodalRoutingHint
:attachments="pendingAttachments"
:capabilities="agentCapabilities"
/>
<!-- 使用组件化的 ChatInput -->
<ChatInput
ref="chatInputRef"
@ -351,6 +358,7 @@ import SkillIcon from '@/components/common/SkillIcon.vue'
import { parsePrompt, deriveTagline } from '@/utils/agentPromptProfile'
import { agentIconColor } from '@/utils/agentIconColor'
import ChatInput from '@/components/chat/ChatInput.vue'
import MultimodalRoutingHint from '@/components/chat/MultimodalRoutingHint.vue'
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
import TalkMode from '@/components/chat/TalkMode.vue'
import ModelSelector from '@/components/chat/ModelSelector.vue'
@ -438,6 +446,11 @@ const activeModels = ref<ActiveModelsInfo | null>(null)
const pendingAttachments = ref<ChatAttachment[]>([])
const uploadingAttachment = ref(false)
// Per-agent capability snapshot for the multimodal routing hint above the
// input box. Refetched whenever the active agent changes; cached locally to
// avoid an extra request per attachment change.
const agentCapabilities = ref<import('@/types').AgentCapabilities | null>(null)
//
const thinkingEnabled = ref(localStorage.getItem('mateclaw_thinking') !== 'off')
const thinkingLevel = computed(() => thinkingEnabled.value ? 'high' : 'off')
@ -1134,6 +1147,19 @@ watch([selectedAgentId, currentConversationId], () => {
syncRouteState()
})
// Refetch agent capabilities (modalities + sidecar config) on agent change so
// the multimodal routing hint above the input box can react synchronously when
// the user attaches an image / video.
watch(selectedAgentId, async (id) => {
if (!id) { agentCapabilities.value = null; return }
try {
const res: any = await agentApi.getCapabilities(id)
agentCapabilities.value = res.data || null
} catch {
agentCapabilities.value = null
}
}, { immediate: true })
// ============ ============
async function loadAgents() {
try {

View File

@ -0,0 +1,359 @@
<template>
<div class="provider-group sidecar-section">
<h3 class="group-title">
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M5 8V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-3"/>
<circle cx="6" cy="13" r="3"/>
<path d="M9 13h12"/>
</svg>
{{ t('settings.models.sidecar.title') }}
<span class="group-hint">{{ t('settings.models.sidecar.hint') }}</span>
</h3>
<div v-if="loading" class="loading-state">{{ t('common.loading') }}</div>
<div v-else class="sidecar-cards">
<!-- Vision sidecar (active) -->
<div class="sidecar-card" :class="{ 'is-configured': !!visionModelId }">
<div class="sidecar-card__head">
<div class="sidecar-card__icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="5" width="18" height="14" rx="2"/>
<circle cx="9" cy="11" r="2"/>
<path d="m21 17-5.5-5.5L9 18"/>
</svg>
</div>
<div class="sidecar-card__title-block">
<div class="sidecar-card__title">{{ t('settings.models.sidecar.vision.label') }}</div>
<div class="sidecar-card__desc">{{ t('settings.models.sidecar.vision.desc') }}</div>
</div>
<span
class="sidecar-card__status"
:class="visionModelId ? 'status--ok' : 'status--idle'"
>{{ visionModelId ? t('common.enabled') : t('settings.models.sidecar.idle') }}</span>
</div>
<div class="sidecar-card__body">
<ModelPicker
v-model="visionModelId"
:models="visionModels"
:placeholder="t('settings.models.sidecar.notConfigured')"
:empty-text="t('settings.models.sidecar.vision.empty')"
:disabled="visionModels.length === 0"
/>
</div>
<div class="sidecar-card__actions">
<span v-if="savedTip === 'vision'" class="sidecar-saved"> {{ t('common.saved') }}</span>
<button
class="card-btn save-btn"
:disabled="saving === 'vision' || !visionDirty"
@click="onSaveVision"
>{{ saving === 'vision' ? t('common.saving') : t('common.save') }}</button>
</div>
</div>
<!-- Video sidecar (reserved; renders dimmer to set expectations) -->
<div class="sidecar-card sidecar-card--reserved">
<div class="sidecar-card__head">
<div class="sidecar-card__icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="5" width="14" height="14" rx="2"/>
<path d="m17 9 5-3v12l-5-3z"/>
</svg>
</div>
<div class="sidecar-card__title-block">
<div class="sidecar-card__title">
{{ t('settings.models.sidecar.video.label') }}
<span class="reserved-badge">{{ t('settings.models.sidecar.reserved') }}</span>
</div>
<div class="sidecar-card__desc">{{ t('settings.models.sidecar.video.desc') }}</div>
</div>
</div>
<div class="sidecar-card__body">
<ModelPicker
v-model="videoModelId"
:models="videoModels"
:placeholder="t('settings.models.sidecar.notConfigured')"
:empty-text="t('settings.models.sidecar.video.empty')"
:disabled="videoModels.length === 0"
/>
</div>
<div class="sidecar-card__actions">
<span v-if="savedTip === 'video'" class="sidecar-saved"> {{ t('common.saved') }}</span>
<button
class="card-btn save-btn"
:disabled="saving === 'video' || !videoDirty"
@click="onSaveVideo"
>{{ saving === 'video' ? t('common.saving') : t('common.save') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { modelApi, settingsApi } from '@/api'
import ModelPicker from '@/components/common/ModelPicker.vue'
interface ModelOption {
id: string | number
name: string
provider: string
modelName: string
}
const { t } = useI18n()
const loading = ref(false)
// Per-card save state. 'vision' / 'video' / null. Lets each card show its own
// "Saving..." label without the buttons fighting each other.
const saving = ref<'vision' | 'video' | null>(null)
const savedTip = ref<'vision' | 'video' | null>(null)
const visionModels = ref<ModelOption[]>([])
const videoModels = ref<ModelOption[]>([])
const visionModelId = ref<string | null>(null)
const videoModelId = ref<string | null>(null)
const initialVision = ref<string | null>(null)
const initialVideo = ref<string | null>(null)
const visionDirty = computed(() => visionModelId.value !== initialVision.value)
const videoDirty = computed(() => videoModelId.value !== initialVideo.value)
async function loadAll() {
loading.value = true
try {
const [visionRes, videoRes, settingsRes] = await Promise.all([
modelApi.listByType('chat', 'vision'),
modelApi.listByType('chat', 'video'),
settingsApi.get(),
])
visionModels.value = (visionRes.data as any[]) || []
videoModels.value = (videoRes.data as any[]) || []
const dto = (settingsRes.data as any) || {}
visionModelId.value = dto.defaultVisionModelId ? String(dto.defaultVisionModelId) : null
videoModelId.value = dto.defaultVideoModelId ? String(dto.defaultVideoModelId) : null
initialVision.value = visionModelId.value
initialVideo.value = videoModelId.value
} catch (e: any) {
console.error('[MultimodalSidecar] Load failed:', e?.message)
} finally {
loading.value = false
}
}
async function persistSettings(payload: { defaultVisionModelId: number | null; defaultVideoModelId: number | null }) {
await settingsApi.update(payload)
}
async function onSaveVision() {
saving.value = 'vision'
savedTip.value = null
try {
// Send vision's pending value plus the *initial* (last-saved) video value
// so saving vision never accidentally clears a video selection the user
// may have edited but not yet committed in that card.
await persistSettings({
defaultVisionModelId: visionModelId.value ? Number(visionModelId.value) : null,
defaultVideoModelId: initialVideo.value ? Number(initialVideo.value) : null,
})
initialVision.value = visionModelId.value
savedTip.value = 'vision'
setTimeout(() => { if (savedTip.value === 'vision') savedTip.value = null }, 2200)
} catch (e: any) {
console.error('[MultimodalSidecar] Save vision failed:', e?.message)
} finally {
saving.value = null
}
}
async function onSaveVideo() {
saving.value = 'video'
savedTip.value = null
try {
await persistSettings({
defaultVisionModelId: initialVision.value ? Number(initialVision.value) : null,
defaultVideoModelId: videoModelId.value ? Number(videoModelId.value) : null,
})
initialVideo.value = videoModelId.value
savedTip.value = 'video'
setTimeout(() => { if (savedTip.value === 'video') savedTip.value = null }, 2200)
} catch (e: any) {
console.error('[MultimodalSidecar] Save video failed:', e?.message)
} finally {
saving.value = null
}
}
// Wipe stale per-card "" tips the moment the user touches that card again.
watch(visionModelId, () => { if (savedTip.value === 'vision') savedTip.value = null })
watch(videoModelId, () => { if (savedTip.value === 'video') savedTip.value = null })
onMounted(loadAll)
defineExpose({ refresh: loadAll })
</script>
<style scoped>
.sidecar-section {
margin-top: 24px;
}
.group-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 16px;
font-weight: 600;
color: var(--mc-text-primary);
}
.group-title__icon {
flex-shrink: 0;
color: var(--mc-text-secondary);
}
.group-hint {
font-size: 12px;
font-weight: 400;
color: var(--mc-text-tertiary);
margin-left: 4px;
}
.loading-state {
padding: 32px;
text-align: center;
color: var(--mc-text-tertiary);
background: var(--mc-bg-sunken);
border-radius: 8px;
}
.sidecar-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 12px;
}
.sidecar-card {
padding: 16px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 14px;
transition: border-color 120ms ease;
}
.sidecar-card.is-configured {
border-color: color-mix(in srgb, var(--mc-primary) 35%, var(--mc-border));
}
.sidecar-card--reserved {
opacity: 0.7;
}
.sidecar-card__head {
display: flex;
align-items: flex-start;
gap: 10px;
}
.sidecar-card__icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: var(--mc-bg-sunken);
color: var(--mc-text-secondary);
}
.sidecar-card.is-configured .sidecar-card__icon {
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.sidecar-card__title-block {
flex: 1;
min-width: 0;
}
.sidecar-card__title {
font-size: 14px;
font-weight: 600;
color: var(--mc-text-primary);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sidecar-card__desc {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--mc-text-tertiary);
}
.sidecar-card__status {
flex-shrink: 0;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.02em;
}
.status--ok {
background: color-mix(in srgb, var(--mc-primary) 12%, transparent);
color: var(--mc-primary);
}
.status--idle {
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
}
.reserved-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sidecar-card__body {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Picker styling is now owned by components/common/ModelPicker.vue. */
.sidecar-card__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.sidecar-saved {
font-size: 12px;
color: var(--mc-success, #22c55e);
animation: fade-in 160ms ease;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.card-btn {
padding: 6px 16px;
font-size: 12px;
border-radius: 4px;
border: 1px solid var(--mc-primary);
background: var(--mc-primary-bg);
color: var(--mc-primary);
cursor: pointer;
font-weight: 600;
transition: opacity 120ms ease, background 120ms ease, color 120ms ease;
}
.card-btn:hover:not(:disabled) {
background: var(--mc-primary);
color: #fff;
}
.card-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>

View File

@ -116,6 +116,10 @@
<!-- Embedding 模型RFC Embedding UI -->
<EmbeddingModelsSection />
<!-- Multimodal sidecar routing: text-only primary models can delegate
image/video understanding to a vision/video model configured here. -->
<MultimodalSidecarSection />
<div v-if="savedTip" class="save-tip">{{ savedTip }}</div>
<!-- Provider Config Modal -->
@ -194,6 +198,7 @@ import type { ProviderInfo, ProviderModelInfo } from '@/types'
import { useProviders } from './useProviders'
import ProviderCard from './ProviderCard.vue'
import EmbeddingModelsSection from './EmbeddingModelsSection.vue'
import MultimodalSidecarSection from './MultimodalSidecarSection.vue'
// RFC-074 PR-1: defer modal JS until the user actually opens one same
// pattern as ChannelEditModal in commit 9300559b. Drops ~30KB from the
// initial Settings/Models route chunk.