feat: 更新模型选择组件和聊天组件的输入逻辑

This commit is contained in:
Wenjie Zhang 2026-05-25 21:02:23 +08:00
parent 3525e357d3
commit 21b16c7c06
8 changed files with 168 additions and 35 deletions

View File

@ -25,6 +25,7 @@
- [ ] rename database table name, such as skills -> agent_skills, subagents -> agent_subagents, mcp, tool_call, 等等 - [ ] rename database table name, such as skills -> agent_skills, subagents -> agent_subagents, mcp, tool_call, 等等
- [x] department 的 id 也不能使用那个索引的 id 来使用了,应该是一个独立的 dept_id需要确认 - [x] department 的 id 也不能使用那个索引的 id 来使用了,应该是一个独立的 dept_id需要确认
- [ ] allow user config skill, and envs - [ ] allow user config skill, and envs
- [ ] 添加 Skill 的权限设计
- [ ] add model retry times to agent context config - [ ] add model retry times to agent context config
- [ ] add user envs when load sandbox - [ ] add user envs when load sandbox
- [ ] Config spacy model 的 load - [ ] Config spacy model 的 load

View File

@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from server.utils.auth_middleware import get_admin_user, get_db from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
from yuxi.services.model_provider_service import ( from yuxi.services.model_provider_service import (
check_credential_status, check_credential_status,
create_provider_config, create_provider_config,
@ -201,7 +201,7 @@ async def refresh_model_cache(
@model_providers.get("/models/v2") @model_providers.get("/models/v2")
async def get_v2_models( async def get_v2_models(
model_type: str = "chat", model_type: str = "chat",
current_user: User = Depends(get_admin_user), _current_user: User = Depends(get_required_user),
): ):
"""获取 v2 格式的模型列表,按 provider 分组。 """获取 v2 格式的模型列表,按 provider 分组。

View File

@ -108,7 +108,7 @@ export const modelProviderApi = {
}, },
getV2Models: async (modelType = 'chat') => { getV2Models: async (modelType = 'chat') => {
return apiAdminGet(`/api/system/model-providers/models/v2?model_type=${modelType}`) return apiGet(`/api/system/model-providers/models/v2?model_type=${modelType}`)
}, },
getCacheStatus: async () => { getCacheStatus: async () => {

View File

@ -120,6 +120,9 @@
<template #actions-left-extra> <template #actions-left-extra>
<slot name="input-actions-left" :has-active-thread="!!currentChatId"></slot> <slot name="input-actions-left" :has-active-thread="!!currentChatId"></slot>
</template> </template>
<template #actions-right-extra>
<slot name="input-actions-right" :has-active-thread="!!currentChatId"></slot>
</template>
</AgentInputArea> </AgentInputArea>
<div class="bottom-actions" v-if="conversations.length > 0"> <div class="bottom-actions" v-if="conversations.length > 0">
@ -205,7 +208,8 @@ import AgentPanel from '@/components/AgentPanel.vue'
// ==================== PROPS & EMITS ==================== // ==================== PROPS & EMITS ====================
const props = defineProps({ const props = defineProps({
agentId: { type: String, default: '' }, agentId: { type: String, default: '' },
singleMode: { type: Boolean, default: true } singleMode: { type: Boolean, default: true },
sendDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['thread-change']) const emit = defineEmits(['thread-change'])
@ -581,7 +585,9 @@ const isReplyLoading = computed(() => {
}) })
const isSendButtonDisabled = computed(() => { const isSendButtonDisabled = computed(() => {
return ( return (
sendCooldownActive.value || ((!userInput.value || !currentAgent.value) && !isProcessing.value) sendCooldownActive.value ||
(props.sendDisabled && !isProcessing.value) ||
((!userInput.value || !currentAgent.value) && !isProcessing.value)
) )
}) })
@ -1259,7 +1265,13 @@ const selectThreadFromRoute = async (threadId) => {
const handleSendMessage = async ({ image } = {}) => { const handleSendMessage = async ({ image } = {}) => {
const text = userInput.value.trim() const text = userInput.value.trim()
const imageContent = image?.imageContent || null const imageContent = image?.imageContent || null
if ((!text && !image) || !currentAgent.value || isProcessing.value || sendCooldownActive.value) if (
(!text && !image) ||
!currentAgent.value ||
isProcessing.value ||
sendCooldownActive.value ||
props.sendDisabled
)
return return
// //
@ -1457,6 +1469,7 @@ const handleSendOrStop = async (payload) => {
return return
} }
} }
if (props.sendDisabled) return
await handleSendMessage(payload) await handleSendMessage(payload)
} }

View File

@ -77,6 +77,7 @@
</template> </template>
<template #actions-left> <template #actions-left>
<div class="input-actions-left"> <div class="input-actions-left">
<slot name="actions-left-extra"></slot>
<a-popover <a-popover
v-if="showTodoEntry" v-if="showTodoEntry"
v-model:open="todoPopoverOpen" v-model:open="todoPopoverOpen"
@ -133,7 +134,7 @@
</template> </template>
<template #actions-right> <template #actions-right>
<div class="input-actions-right"> <div class="input-actions-right">
<slot name="actions-left-extra"></slot> <slot name="actions-right-extra"></slot>
</div> </div>
</template> </template>
</MessageInputComponent> </MessageInputComponent>
@ -401,7 +402,7 @@ const getTodoStatusLabel = (status) => {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 8px; padding: 6px 8px;
// height: 28px; height: 30px;
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
color: var(--gray-600); color: var(--gray-600);

View File

@ -218,7 +218,7 @@
size="small" size="small"
type="text" type="text"
danger danger
v-if="!isBuildActive" v-if="graphBuildStatus?.locked && !isBuildActive"
@click="confirmResetGraph" @click="confirmResetGraph"
>重置</a-button >重置</a-button
> >
@ -378,7 +378,7 @@ const extractorTypeOptions = [
{ {
value: 'spacy', value: 'spacy',
label: 'spaCy', label: 'spaCy',
description: '使用本地 NER 模型抽取实体', description: '【Beta】使用本地 NER 模型抽取实体',
icon: ScanText icon: ScanText
} }
] ]

View File

@ -1,18 +1,16 @@
<template> <template>
<a-dropdown trigger="click" :open="dropdownOpen" @open-change="handleOpenChange"> <a-dropdown trigger="click" :open="dropdownOpen" :disabled="props.disabled" @open-change="handleOpenChange">
<div class="model-select" :class="modelSelectClasses" @click.prevent> <div class="model-select" :class="modelSelectClasses" @click.prevent.stop @mousedown.stop>
<div class="model-select-content"> <div class="model-select-content">
<div class="model-info"> <div class="model-info">
<a-tooltip :title="displayModelTooltip" placement="right"> <span class="model-text text" :title="displayModelTitle">{{ displayModelText }}</span>
<span class="model-text text"> {{ displayModelText }} </span>
</a-tooltip>
</div> </div>
<div class="model-status-controls"> <div v-if="resolvedSize !== 'nano'" class="model-status-controls">
<span <span
v-if="state.currentModelStatus" v-if="state.currentModelStatus"
class="model-status-indicator" class="model-status-indicator"
:class="state.currentModelStatus.status" :class="state.currentModelStatus.status"
:title="getCurrentModelStatusTooltip()" :title="getCurrentModelStatusTitle()"
> >
{{ modelStatusIcon }} {{ modelStatusIcon }}
</span> </span>
@ -21,7 +19,7 @@
type="text" type="text"
:loading="state.checkingStatus" :loading="state.checkingStatus"
@click.stop="checkCurrentModelStatus" @click.stop="checkCurrentModelStatus"
:disabled="state.checkingStatus" :disabled="props.disabled || state.checkingStatus"
class="status-check-button" class="status-check-button"
> >
{{ state.checkingStatus ? '检查中...' : '检查' }} {{ state.checkingStatus ? '检查中...' : '检查' }}
@ -34,16 +32,15 @@
<div class="model-search"> <div class="model-search">
<a-input v-model:value="modelSearchKeyword" placeholder="搜索模型" allow-clear @keydown.stop> <a-input v-model:value="modelSearchKeyword" placeholder="搜索模型" allow-clear @keydown.stop>
<template #suffix> <template #suffix>
<a-tooltip title="刷新缓存"> <button
<button :disabled="props.disabled || state.refreshingCache"
:disabled="state.refreshingCache" :title="state.refreshingCache ? '刷新中...' : '刷新缓存'"
class="cache-refresh-button" class="cache-refresh-button"
@mousedown.prevent.stop @mousedown.prevent.stop
@click.stop="refreshCache" @click.stop="refreshCache"
> >
<RefreshCw :size="13" :class="{ spin: state.refreshingCache }" /> <RefreshCw :size="13" :class="{ spin: state.refreshingCache }" />
</button> </button>
</a-tooltip>
</template> </template>
</a-input> </a-input>
</div> </div>
@ -91,7 +88,11 @@ const props = defineProps({
size: { size: {
type: String, type: String,
default: 'small', default: 'small',
validator: (value) => ['small', 'middle', 'large'].includes(value) validator: (value) => ['nano', 'small', 'middle', 'large'].includes(value)
},
disabled: {
type: Boolean,
default: false
}, },
displayName: { displayName: {
type: String, type: String,
@ -151,13 +152,17 @@ const fetchV2Models = async () => {
// //
const handleOpenChange = (open) => { const handleOpenChange = (open) => {
if (props.disabled) {
dropdownOpen.value = false
return
}
dropdownOpen.value = open dropdownOpen.value = open
if (open) fetchV2Models() if (open) fetchV2Models()
} }
// //
const refreshCache = async () => { const refreshCache = async () => {
if (state.refreshingCache) return if (props.disabled || state.refreshingCache) return
state.refreshingCache = true state.refreshingCache = true
try { try {
await modelProviderApi.refreshModelCache() await modelProviderApi.refreshModelCache()
@ -180,8 +185,10 @@ const state = reactive({
const resolvedSize = computed(() => props.size || 'small') const resolvedSize = computed(() => props.size || 'small')
const modelSelectClasses = computed(() => ({ const modelSelectClasses = computed(() => ({
'model-select--nano': resolvedSize.value === 'nano',
'model-select--middle': resolvedSize.value === 'middle', 'model-select--middle': resolvedSize.value === 'middle',
'model-select--large': resolvedSize.value === 'large' 'model-select--large': resolvedSize.value === 'large',
'model-select--disabled': props.disabled
})) }))
const buttonSize = computed(() => { const buttonSize = computed(() => {
if (resolvedSize.value === 'large') return 'large' if (resolvedSize.value === 'large') return 'large'
@ -206,10 +213,11 @@ const displayModelText = computed(() => {
return spec return spec
}) })
const displayModelTooltip = computed(() => props.model_spec || props.placeholder) const displayModelTitle = computed(() => props.model_spec || props.placeholder)
// //
const checkCurrentModelStatus = async () => { const checkCurrentModelStatus = async () => {
if (props.disabled) return
const spec = props.model_spec const spec = props.model_spec
if (!spec) return if (!spec) return
@ -238,7 +246,7 @@ const modelStatusIcon = computed(() => {
return '○' return '○'
}) })
const getCurrentModelStatusTooltip = () => { const getCurrentModelStatusTitle = () => {
const status = state.currentModelStatus const status = state.currentModelStatus
if (!status) return '状态未知' if (!status) return '状态未知'
@ -253,6 +261,7 @@ const getCurrentModelStatusTooltip = () => {
// v2 // v2
const handleSelectV2Model = (spec) => { const handleSelectV2Model = (spec) => {
if (props.disabled) return
emit('select-model', spec) emit('select-model', spec)
dropdownOpen.value = false dropdownOpen.value = false
} }
@ -282,6 +291,34 @@ const handleSelectV2Model = (spec) => {
cursor: pointer; cursor: pointer;
} }
.model-select--nano {
max-width: 100%;
height: 30px;
padding: 6px 8px;
border: none;
border-radius: 8px;
font-size: 13px;
line-height: 1;
color: var(--gray-600);
background: transparent;
transition: all 0.2s ease;
user-select: none;
}
.model-select--nano:hover {
color: var(--gray-900);
background: var(--gray-50);
}
.model-select--nano .model-text {
color: currentColor;
}
.model-select--disabled {
cursor: not-allowed;
opacity: 0.55;
}
.model-dropdown { .model-dropdown {
min-width: 280px; min-width: 280px;
max-width: 420px; max-width: 420px;
@ -302,5 +339,24 @@ const handleSelectV2Model = (spec) => {
overflow-y: auto; overflow-y: auto;
box-shadow: none; box-shadow: none;
} }
.ant-dropdown-menu-item,
.ant-dropdown-menu-submenu-title {
border-radius: 6px;
}
.ant-dropdown-menu-item:hover,
.ant-dropdown-menu-submenu-title:hover,
.ant-dropdown-menu-item-active,
.ant-dropdown-menu-submenu-title-active {
background: var(--gray-50);
}
.ant-dropdown-menu-item-selected,
.ant-dropdown-menu-item-selected:hover,
.ant-dropdown-menu-item-selected.ant-dropdown-menu-item-active {
color: var(--gray-1000);
background: var(--main-50);
}
} }
</style> </style>

View File

@ -6,9 +6,22 @@
<AgentChatComponent <AgentChatComponent
ref="chatComponentRef" ref="chatComponentRef"
:single-mode="false" :single-mode="false"
:send-disabled="isSavingInputModel"
@thread-change="handleThreadChange" @thread-change="handleThreadChange"
> >
<template #input-actions-left="{ hasActiveThread }"> <template #input-actions-left>
<div v-if="showInputModelSelector" class="input-model-selector">
<ModelSelectorComponent
:model_spec="currentInputModelSpec"
:disabled="isInputModelSelectorDisabled"
size="nano"
display-name="mini"
@select-model="handleInputModelChange"
/>
</div>
</template>
<template #input-actions-right="{ hasActiveThread }">
<a-dropdown <a-dropdown
v-if="selectedAgentId" v-if="selectedAgentId"
v-model:open="agentDropdownOpen" v-model:open="agentDropdownOpen"
@ -146,6 +159,7 @@ import { message } from 'ant-design-vue'
import { Settings2, Ellipsis, ChevronDown, Check, FolderKanban } from 'lucide-vue-next' import { Settings2, Ellipsis, ChevronDown, Check, FolderKanban } from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AgentChatComponent from '@/components/AgentChatComponent.vue' import AgentChatComponent from '@/components/AgentChatComponent.vue'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import FeedbackModalComponent from '@/components/dashboard/FeedbackModalComponent.vue' import FeedbackModalComponent from '@/components/dashboard/FeedbackModalComponent.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { isBuiltinAgent, useAgentStore } from '@/stores/agent' import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
@ -169,9 +183,11 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
// agentStore // agentStore
const { agents, selectedAgentId, isLoadingConfig } = storeToRefs(agentStore) const { agents, selectedAgentId, selectedAgent, agentConfig, configurableItems, isLoadingConfig } =
storeToRefs(agentStore)
const syncingRouteThread = ref(false) const syncingRouteThread = ref(false)
const isSavingInputModel = ref(false)
const getRouteThreadId = () => { const getRouteThreadId = () => {
const value = route.params.thread_id const value = route.params.thread_id
@ -246,6 +262,42 @@ const currentAgentLabel = computed(() => {
const currentAgentIcon = computed(() => currentAgentOption.value?.icon || defaultAgentIcon) const currentAgentIcon = computed(() => currentAgentOption.value?.icon || defaultAgentIcon)
const inputModelKey = computed(() => {
if (configurableItems.value?.model?.kind === 'llm') return 'model'
return (
Object.entries(configurableItems.value || {}).find(
([key, item]) => key !== 'subagents_model' && item?.kind === 'llm'
)?.[0] || ''
)
})
const currentInputModelSpec = computed(() => {
const key = inputModelKey.value
return key ? agentConfig.value?.[key] || '' : ''
})
const showInputModelSelector = computed(() => Boolean(selectedAgentId.value && inputModelKey.value))
const isInputModelSelectorDisabled = computed(
() => isLoadingConfig.value || isSavingInputModel.value || !selectedAgent.value?.can_manage
)
const handleInputModelChange = async (spec) => {
const key = inputModelKey.value
if (!key || typeof spec !== 'string' || !spec || spec === currentInputModelSpec.value) return
if (isInputModelSelectorDisabled.value) return
const previousSpec = currentInputModelSpec.value
isSavingInputModel.value = true
try {
agentStore.updateAgentConfig({ [key]: spec })
await agentStore.saveAgentConfig()
} catch {
agentStore.updateAgentConfig({ [key]: previousSpec })
} finally {
isSavingInputModel.value = false
}
}
const agentDropdownOpen = ref(false) const agentDropdownOpen = ref(false)
const handleAgentSwitch = async (agentId, hasActiveThread) => { const handleAgentSwitch = async (agentId, hasActiveThread) => {
@ -375,6 +427,14 @@ const handleFeedback = () => {
overflow: hidden; overflow: hidden;
} }
.input-model-selector {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0;
max-width: min(168px, calc(100vw - 160px));
}
.config-dropdown-trigger { .config-dropdown-trigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -391,6 +451,7 @@ const handleFeedback = () => {
.config-dropdown-agent-icon { .config-dropdown-agent-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 3px;
flex-shrink: 0; flex-shrink: 0;
object-fit: contain; object-fit: contain;
} }
@ -585,6 +646,7 @@ const handleFeedback = () => {
width: 24px; width: 24px;
height: 24px; height: 24px;
object-fit: contain; object-fit: contain;
border-radius: 4px;
} }
.config-dropdown-overlay .config-dropdown-item-badge { .config-dropdown-overlay .config-dropdown-item-badge {