From 9ca4e1f23a519e9876117976b9f8dabc5e04343a Mon Sep 17 00:00:00 2001 From: wizardchen Date: Thu, 25 Jun 2026 21:02:14 +0800 Subject: [PATCH] feat(agent): enforce model readiness and polish selector UX Block chat when agents lack an explicit chat or rerank model, guide users through the selector and editor with field highlight, and align session QA checks with the same readiness rules on backend and frontend. --- frontend/src/components/AgentSelector.vue | 1021 +++++++++-------- frontend/src/components/Input-field.vue | 307 ++--- frontend/src/i18n/locales/en-US.ts | 14 +- frontend/src/i18n/locales/ko-KR.ts | 12 +- frontend/src/i18n/locales/ru-RU.ts | 14 +- frontend/src/i18n/locales/zh-CN.ts | 16 +- frontend/src/utils/agent-readiness.test.mjs | 94 ++ frontend/src/utils/agent-readiness.ts | 93 ++ frontend/src/views/agent/AgentEditorModal.vue | 114 +- frontend/src/views/agent/AgentList.vue | 9 +- .../service/agent_rerank_requirement_test.go | 68 ++ internal/application/service/agent_share.go | 13 +- .../application/service/session_agent_qa.go | 16 +- .../service/session_knowledge_qa_test.go | 7 +- .../service/session_model_requirement_test.go | 85 ++ .../application/service/session_qa_helpers.go | 23 +- 16 files changed, 1240 insertions(+), 666 deletions(-) create mode 100644 frontend/src/utils/agent-readiness.test.mjs create mode 100644 frontend/src/utils/agent-readiness.ts create mode 100644 internal/application/service/agent_rerank_requirement_test.go create mode 100644 internal/application/service/session_model_requirement_test.go diff --git a/frontend/src/components/AgentSelector.vue b/frontend/src/components/AgentSelector.vue index c31e086a4..96281f3f7 100644 --- a/frontend/src/components/AgentSelector.vue +++ b/frontend/src/components/AgentSelector.vue @@ -1,12 +1,11 @@ @@ -274,13 +202,20 @@ import { ref, computed, watch, nextTick } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; -import { Icon as TIcon, Popup as TPopup, Tooltip as TTooltip } from 'tdesign-vue-next'; +import { Icon as TIcon, Tooltip as TTooltip } from 'tdesign-vue-next'; import { type CustomAgent, BUILTIN_QUICK_ANSWER_ID, BUILTIN_SMART_REASONING_ID } from '@/api/agent'; import AgentAvatar from '@/components/AgentAvatar.vue'; import { useOrganizationStore } from '@/stores/organization'; import { useSettingsStore } from '@/stores/settings'; import type { SharedAgentInfo } from '@/api/organization'; import { getRootZoom, rectToCssPx, cssViewportSize } from '@/utils/zoom'; +import { type ModelConfig } from '@/api/model'; +import { + getAgentNotReadyReasonKeys, + resolveAgentNotReadySection, + resolveAgentNotReadyHighlight, + type AgentNotReadyReasonKey, +} from '@/utils/agent-readiness'; const { t } = useI18n(); const router = useRouter(); @@ -291,8 +226,8 @@ const props = defineProps<{ visible: boolean; anchorEl?: HTMLElement; currentAgentId: string; - /** 由父组件加载的智能体列表,避免下拉打开时重复请求 agents / shared-agents */ agents?: CustomAgent[]; + allModels?: ModelConfig[]; }>(); const emit = defineEmits<{ @@ -300,125 +235,230 @@ const emit = defineEmits<{ (e: 'select', agent: CustomAgent, sourceTenantId?: string): void; }>(); +type AgentDetailTarget = { + agent: CustomAgent; + sourceTenantId?: string; + sharedMeta?: { org_name?: string; shared_by_username?: string }; +}; + const dropdownStyle = ref>({}); +const activeDetail = ref(null); +const detailAnchorEl = ref(null); +const detailPanelStyle = ref>({}); +let detailHideTimer: ReturnType | null = null; -// 父组件已按「当前租户停用」过滤,此处直接使用 const agentsList = computed(() => props.agents ?? []); -// 内置智能体(从 API 获取,对特定 ID 使用本地化名称) const builtinAgents = computed(() => { - // 从 API 获取的内置智能体(内置无 disabled 概念,全部展示) const apiBuiltins = agentsList.value.filter(a => a.is_builtin); - - // 对特定内置智能体使用本地化名称和描述 return apiBuiltins.map(agent => { if (agent.id === BUILTIN_QUICK_ANSWER_ID) { - return { - ...agent, - name: t('input.normalMode'), - description: t('input.normalModeDesc'), - }; - } else if (agent.id === BUILTIN_SMART_REASONING_ID) { - return { - ...agent, - name: t('input.agentMode'), - description: t('input.agentModeDesc'), - }; + return { ...agent, name: t('input.normalMode'), description: t('input.normalModeDesc') }; + } + if (agent.id === BUILTIN_SMART_REASONING_ID) { + return { ...agent, name: t('input.agentMode'), description: t('input.agentModeDesc') }; } - // 其他内置智能体使用 API 返回的名称和描述 return agent; }); }); -// 自定义智能体(我的) -const customAgents = computed(() => { - return agentsList.value.filter(a => !a.is_builtin); -}); +const customAgents = computed(() => agentsList.value.filter(a => !a.is_builtin)); -// 共享给我的智能体(仅展示当前用户未停用的) const sharedAgentsList = computed(() => - (orgStore.sharedAgents || []).filter(shared => !shared.disabled_by_me) + (orgStore.sharedAgents || []).filter(shared => !shared.disabled_by_me), ); -// 当前选中的来源租户(共享智能体时) const currentAgentSourceTenantId = computed(() => settingsStore.selectedAgentSourceTenantId ?? null); const isSharedAgentSelected = (shared: SharedAgentInfo) => props.currentAgentId === shared.agent.id && currentAgentSourceTenantId.value === String(shared.source_tenant_id); -// 我的智能体(内置或自定义)选中态:仅当未选共享来源时 const isMyAgentSelected = (agent: CustomAgent) => props.currentAgentId === agent.id && !currentAgentSourceTenantId.value; -// 获取知识库能力描述 +const isDetailCurrent = computed(() => { + const detail = activeDetail.value; + if (!detail) return false; + if (detail.sourceTenantId) { + return props.currentAgentId === detail.agent.id + && currentAgentSourceTenantId.value === detail.sourceTenantId; + } + return isMyAgentSelected(detail.agent); +}); + +const activeDetailNotReadyLabels = computed(() => { + const detail = activeDetail.value; + if (!detail) return []; + return getAgentNotReadyLabels(detail.agent, detail.sourceTenantId); +}); + const getKbCapability = (agent: CustomAgent): string => { const config = agent.config || {}; - if (config.kb_selection_mode === 'none') { - return ''; - } else if (config.knowledge_bases && config.knowledge_bases.length > 0) { + if (config.kb_selection_mode === 'none') return ''; + if (config.knowledge_bases && config.knowledge_bases.length > 0) { return t('agent.capabilities.kbCount', { count: config.knowledge_bases.length }); - } else if (config.kb_selection_mode === 'all') { - return t('agent.capabilities.kbAll'); } + if (config.kb_selection_mode === 'all') return t('agent.capabilities.kbAll'); return ''; }; -// 获取 MCP 能力描述(更详细:全部 / 指定 N 个) const getMcpCapability = (agent: CustomAgent): string => { const config = agent.config || {}; if (config.mcp_selection_mode === 'none' || (!config.mcp_services?.length && config.mcp_selection_mode !== 'all')) { return ''; } - if (config.mcp_selection_mode === 'all') { - return t('agent.shareScope.mcpAll'); - } + if (config.mcp_selection_mode === 'all') return t('agent.shareScope.mcpAll'); if (config.mcp_services?.length) { return t('agent.shareScope.mcpSelected', { count: config.mcp_services.length }); } return t('agent.capabilities.mcpEnabled'); }; -// 选择智能体(我的或内置) -const selectAgent = (agent: CustomAgent) => { - emit('select', agent); -}; +const modelsList = computed(() => props.allModels ?? []); -// 选择共享智能体 +const formatAgentNotReadyReasons = ( + reasonKeys: AgentNotReadyReasonKey[], + isBuiltin: boolean, +): string[] => { + return reasonKeys.map((key) => { + if (key === 'summary_model') { + return isBuiltin ? t('input.agentMissingSummaryModel') : t('input.customAgentMissingSummaryModel'); + } + if (key === 'rerank_model') { + return isBuiltin ? t('input.agentMissingRerankModel') : t('input.customAgentMissingRerankModel'); + } + return t('input.agentMissingAllowedTools'); + }); +}; + +const getAgentNotReadyReasonKeysFor = (agent: CustomAgent, sourceTenantId?: string) => { + const isAgentMode = agent.config?.agent_mode === 'smart-reasoning'; + const isSharedAgent = !!sourceTenantId; + return getAgentNotReadyReasonKeys(agent.config, modelsList.value, { + isAgentMode, + isSharedAgent, + }); +}; + +const getAgentNotReadyLabels = (agent: CustomAgent, sourceTenantId?: string): string[] => { + return formatAgentNotReadyReasons( + getAgentNotReadyReasonKeysFor(agent, sourceTenantId), + agent.is_builtin, + ); +}; + +const clearDetailHideTimer = () => { + if (detailHideTimer) { + clearTimeout(detailHideTimer); + detailHideTimer = null; + } +}; + +const updateDetailPanelPosition = () => { + const el = detailAnchorEl.value; + if (!el || !activeDetail.value) return; + + const zoom = getRootZoom(); + const rect = rectToCssPx(el.getBoundingClientRect(), zoom); + const { width: vw, height: vh } = cssViewportSize(zoom); + const panelWidth = 200; + const gap = 6; + let left = rect.right + gap; + let top = rect.top; + + if (left + panelWidth > vw - 8) { + left = Math.max(8, vw - panelWidth - 8); + } + if (top + 240 > vh - 8) { + top = Math.max(8, vh - 248); + } + + detailPanelStyle.value = { + position: 'fixed', + left: `${Math.round(left)}px`, + top: `${Math.round(top)}px`, + width: `${panelWidth}px`, + zIndex: '10002', + }; +}; + +const onOptionEnter = (agent: CustomAgent, event: MouseEvent, sourceTenantId?: string, sharedMeta?: AgentDetailTarget['sharedMeta']) => { + clearDetailHideTimer(); + detailAnchorEl.value = event.currentTarget as HTMLElement; + activeDetail.value = { agent, sourceTenantId, sharedMeta }; + nextTick(() => updateDetailPanelPosition()); +}; + +const onSharedOptionEnter = (shared: SharedAgentInfo, event: MouseEvent) => { + onOptionEnter(shared.agent as CustomAgent, event, String(shared.source_tenant_id), { + org_name: shared.org_name, + shared_by_username: shared.shared_by_username, + }); +}; + +const onOptionLeave = () => { + detailHideTimer = setTimeout(() => { + activeDetail.value = null; + detailAnchorEl.value = null; + }, 300); +}; + +const onDetailPanelEnter = () => { + clearDetailHideTimer(); +}; + +const onDetailPanelLeave = () => { + onOptionLeave(); +}; + +const hideDetailPanel = () => { + clearDetailHideTimer(); + activeDetail.value = null; + detailAnchorEl.value = null; +}; + +const selectAgent = (agent: CustomAgent) => { + if (getAgentNotReadyLabels(agent).length > 0) return; + emit('select', agent); +}; + const selectSharedAgent = (shared: SharedAgentInfo) => { - emit('select', shared.agent as CustomAgent, String(shared.source_tenant_id)); + const sourceTenantId = String(shared.source_tenant_id); + if (getAgentNotReadyLabels(shared.agent, sourceTenantId).length > 0) return; + emit('select', shared.agent as CustomAgent, sourceTenantId); }; -// 跳转到智能体设置页面 -const goToSettings = (agent: CustomAgent) => { +const goToSettings = (agent: CustomAgent, sourceTenantId?: string) => { + const reasonKeys = getAgentNotReadyReasonKeysFor(agent, sourceTenantId); + const section = reasonKeys.length > 0 ? resolveAgentNotReadySection(reasonKeys) : 'basic'; + const highlight = resolveAgentNotReadyHighlight(reasonKeys); + hideDetailPanel(); emit('close'); router.push({ path: '/platform/agents', - query: { edit: agent.id } + query: { + edit: agent.id, + section, + ...(highlight ? { highlight } : {}), + }, }); }; -// 更新下拉框位置(与模型选择器一致) const updateDropdownPosition = () => { if (!props.anchorEl) return; - // Normalize everything to CSS pixels up front so we can compare anchor - // coords, viewport bounds, and the dropdown's own width/height in a single - // coordinate system. `getBoundingClientRect()` and `window.innerWidth/Height` - // report visual pixels which are pre-multiplied by the root zoom. const zoom = getRootZoom(); const rect = rectToCssPx(props.anchorEl.getBoundingClientRect(), zoom); const { width: vw, height: vh } = cssViewportSize(zoom); - const dropdownWidth = 200; + const dropdownWidth = 240; const offsetY = 8; - // 水平位置:左对齐 let left = Math.floor(rect.left); const minLeft = 16; const maxLeft = Math.max(16, vw - dropdownWidth - 16); left = Math.max(minLeft, Math.min(maxLeft, left)); - // 垂直位置 const preferredDropdownHeight = 320; const minDropdownHeight = 100; const topMargin = 20; @@ -429,15 +469,13 @@ const updateDropdownPosition = () => { if (spaceBelow >= minDropdownHeight + offsetY) { actualHeight = Math.min(preferredDropdownHeight, spaceBelow - offsetY - 16); - const top = Math.floor(rect.bottom + offsetY); - dropdownStyle.value = { position: 'fixed', width: `${dropdownWidth}px`, left: `${left}px`, - top: `${top}px`, + top: `${Math.floor(rect.bottom + offsetY)}px`, maxHeight: `${actualHeight}px`, - zIndex: '9999' + zIndex: '10001', }; } else { const availableHeight = spaceAbove - offsetY - topMargin; @@ -445,59 +483,70 @@ const updateDropdownPosition = () => { ? preferredDropdownHeight : Math.max(minDropdownHeight, availableHeight); - const bottom = vh - rect.top + offsetY; - dropdownStyle.value = { position: 'fixed', width: `${dropdownWidth}px`, left: `${left}px`, - bottom: `${bottom}px`, + bottom: `${vh - rect.top + offsetY}px`, maxHeight: `${actualHeight}px`, - zIndex: '9999' + zIndex: '10001', }; } }; -// 监听显示状态(仅更新位置,数据由父组件加载后通过 props.agents 传入) watch(() => props.visible, (newVal) => { if (newVal) { - nextTick(() => { - updateDropdownPosition(); - }); + nextTick(() => updateDropdownPosition()); + } else { + hideDetailPanel(); } }); - - diff --git a/frontend/src/components/Input-field.vue b/frontend/src/components/Input-field.vue index 62c0de36a..2e97c3c5c 100644 --- a/frontend/src/components/Input-field.vue +++ b/frontend/src/components/Input-field.vue @@ -26,6 +26,12 @@ import { toolsConsumeFiles, type ScopeCapabilities, } from '@/utils/tool-capabilities'; +import { + getAgentNotReadyReasonKeys, + resolveAgentNotReadySection, + resolveAgentNotReadyHighlight, + type AgentNotReadyReasonKey, +} from '@/utils/agent-readiness'; const route = useRoute(); const router = useRouter(); @@ -37,6 +43,7 @@ const chatResources = useChatResourcesStore(); const { agents, disabledOwnAgentIds, + allModels, chatModels: availableModels, webSearchProviders, } = storeToRefs(chatResources); @@ -1608,10 +1615,14 @@ const createSession = async (val: string) => { return; } + if (!chatResources.isFresh('models')) { + await loadChatModels() + } + // 发送前校验当前选中的智能体(含默认快速问答)是否已配置完成 const agentToCheck = selectedAgent.value; let actualAgent = agentToCheck; - if (agentToCheck.is_builtin) { + if (agentToCheck.is_builtin && !settingsStore.selectedAgentSourceTenantId) { let builtin = agents.value.find(a => a.id === selectedAgentId.value); if (!builtin) { await loadAgents(); @@ -1620,11 +1631,9 @@ const createSession = async (val: string) => { actualAgent = builtin || agentToCheck; } const isAgentMode = actualAgent.config?.agent_mode === 'smart-reasoning'; - const notReadyReasons = actualAgent.is_builtin - ? getBuiltinAgentNotReadyReasons(actualAgent, isAgentMode) - : getCustomAgentNotReadyReasons(actualAgent); + const { keys: notReadyKeys, labels: notReadyReasons } = collectAgentNotReadyReasons(actualAgent, isAgentMode); if (notReadyReasons.length > 0) { - showAgentNotReadyMessage(actualAgent, notReadyReasons); + showAgentNotReadyMessage(actualAgent, notReadyReasons, notReadyKeys); return; } // 获取@提及的知识库和文件信息 @@ -1762,14 +1771,20 @@ const toggleAgentModeSelector = () => { } } -const selectAgentMode = (mode: 'quick-answer' | 'smart-reasoning') => { +const selectAgentMode = async (mode: 'quick-answer' | 'smart-reasoning') => { + if (!chatResources.isFresh('models')) { + await loadChatModels() + } + const builtinAgentId = mode === 'smart-reasoning' ? BUILTIN_SMART_REASONING_ID : BUILTIN_QUICK_ANSWER_ID; const builtinAgent = agents.value.find(a => a.id === builtinAgentId); if (builtinAgent) { - const notReadyReasons = getBuiltinAgentNotReadyReasons(builtinAgent, mode === 'smart-reasoning'); + const { labels: notReadyReasons } = collectAgentNotReadyReasons( + builtinAgent, + mode === 'smart-reasoning', + ); if (notReadyReasons.length > 0) { - showAgentNotReadyMessage(builtinAgent, notReadyReasons); showAgentModeSelector.value = false; return; } @@ -1786,21 +1801,27 @@ const selectAgentMode = (mode: 'quick-answer' | 'smart-reasoning') => { } // 选择智能体(新版);sourceTenantId 为共享智能体时传入 -const handleSelectAgent = (agent: CustomAgent, sourceTenantId?: string) => { +const handleSelectAgent = async (agent: CustomAgent, sourceTenantId?: string) => { + if (!chatResources.isFresh('models')) { + await loadChatModels() + } + // 根据智能体的 agent_mode 判断是否为 Agent 模式 const isAgentType = agent.config?.agent_mode === 'smart-reasoning'; // 统一检查智能体是否就绪(内置和自定义智能体使用相同逻辑) - const actualAgent = agent.is_builtin + const actualAgent = agent.is_builtin && !sourceTenantId ? (agents.value.find(a => a.id === agent.id) || agent) : agent; - const notReadyReasons = agent.is_builtin - ? getBuiltinAgentNotReadyReasons(actualAgent, isAgentType) - : getCustomAgentNotReadyReasons(actualAgent); + const { labels: notReadyReasons } = collectAgentNotReadyReasons( + actualAgent, + isAgentType, + sourceTenantId, + ); if (notReadyReasons.length > 0) { - showAgentNotReadyMessage(agent, notReadyReasons); + showAgentModeSelector.value = false; return; } @@ -1933,64 +1954,77 @@ const handleGoToWebSearchSettings = () => { }; const handleGoToAgentSettings = (section?: string) => { - // 跳转到智能体列表页并打开编辑弹窗 - if (selectedAgent.value && !selectedAgent.value.is_builtin) { - const query: Record = { edit: selectedAgent.value.id }; - if (section) { - query.section = section; - } - router.push({ path: '/platform/agents', query }); - } else { + const agent = selectedAgent.value; + if (!agent) { router.push('/platform/agents'); + return; } + const query: Record = { edit: agent.id }; + if (section) { + query.section = section; + } + router.push({ path: '/platform/agents', query }); }; -// 获取内置智能体不就绪的原因 -const getBuiltinAgentNotReadyReasons = (agent: CustomAgent, isAgentMode: boolean): string[] => { - const reasons: string[] = [] - const config = agent.config || {} - - // 内置智能体会自动回退到租户的默认模型,因此不再在前端强制校验 model_id - - // 检查重排模型(Rerank Model)- 仅当允许使用 knowledge_search 工具时需要 - // 内置智能体允许重排模型为空(使用默认配置) - // const hasKnowledgeSearchTool = config.allowed_tools && config.allowed_tools.includes('knowledge_search') - // if (hasKnowledgeSearchTool) { - // if (!config.rerank_model_id || config.rerank_model_id.trim() === '') { - // reasons.push(t('input.customAgentMissingRerankModel')) - // } - // } - - // Agent 模式还需要检查允许的工具 - if (isAgentMode) { - if (!config.allowed_tools || config.allowed_tools.length === 0) { - reasons.push(t('input.agentMissingAllowedTools')) +const formatAgentNotReadyReasons = ( + reasonKeys: AgentNotReadyReasonKey[], + isBuiltin: boolean, +): string[] => { + return reasonKeys.map((key) => { + if (key === 'summary_model') { + return isBuiltin + ? t('input.agentMissingSummaryModel') + : t('input.customAgentMissingSummaryModel'); } - } + if (key === 'rerank_model') { + return isBuiltin + ? t('input.agentMissingRerankModel') + : t('input.customAgentMissingRerankModel'); + } + return t('input.agentMissingAllowedTools'); + }); +}; - return reasons -} +const collectAgentNotReadyReasons = ( + agent: CustomAgent, + isAgentMode: boolean, + sourceTenantId?: string, +): { keys: AgentNotReadyReasonKey[]; labels: string[] } => { + const isSharedAgent = !!(sourceTenantId || settingsStore.selectedAgentSourceTenantId); + const keys = getAgentNotReadyReasonKeys(agent.config, allModels.value, { + isAgentMode, + isSharedAgent, + }); + return { + keys, + labels: formatAgentNotReadyReasons(keys, agent.is_builtin), + }; +}; -// 获取自定义智能体不就绪的原因(非 Agent 模式,快速回答) -const getCustomAgentNotReadyReasons = (agent: CustomAgent): string[] => { - const reasons: string[] = [] - const config = agent.config || {} - - // 检查对话模型(Summary Model) - if (!config.model_id || config.model_id.trim() === '') { - reasons.push(t('input.customAgentMissingSummaryModel')) - } - // Rerank 模型不在此处强制校验:当 knowledge_search 实际命中 RAG 知识库时, - // 后端会优先使用 agent.rerank_model_id,未配置则回退到租户默认 rerank 模型; - // 仅在两者都缺失时由后端报错。这样可以避免"作用域内无 RAG KB 却被拦"的误报, - // 并支持后续添加 RAG KB 时的自动兜底。 - - return reasons -} +const goToAgentEditor = ( + agent: CustomAgent, + section = 'model', + highlight?: AgentNotReadyReasonKey, +) => { + router.push({ + path: '/platform/agents', + query: { + edit: agent.id, + section, + ...(highlight ? { highlight } : {}), + }, + }); +}; // 显示智能体未就绪的消息(统一处理内置和自定义智能体) -const showAgentNotReadyMessage = (agent: CustomAgent, reasons: string[]) => { +const showAgentNotReadyMessage = ( + agent: CustomAgent, + reasons: string[], + reasonKeys?: AgentNotReadyReasonKey[], +) => { const reasonsText = reasons.join('、') + const section = resolveAgentNotReadySection(reasonKeys || ['summary_model']) + const highlight = resolveAgentNotReadyHighlight(reasonKeys || ['summary_model']) const messageContent = h('div', { style: 'display: flex; flex-direction: column; gap: 8px; max-width: 320px;' }, [ h('span', { style: 'color: var(--td-text-color-primary); line-height: 1.5;' }, t('input.agentNotReadyDetail', { agentName: agent.name, reasons: reasonsText })), @@ -1998,7 +2032,7 @@ const showAgentNotReadyMessage = (agent: CustomAgent, reasons: string[]) => { href: '#', onClick: (e: Event) => { e.preventDefault(); - router.push(`/platform/agents?edit=${agent.id}`); + goToAgentEditor(agent, section, highlight); }, style: 'color: var(--td-brand-color); text-decoration: none; font-weight: 500; cursor: pointer; align-self: flex-start;', onMouseenter: (e: Event) => { @@ -2174,7 +2208,8 @@ defineExpose({ @@ -2308,18 +2343,21 @@ defineExpose({
-
-
- {{ modelDisplayName(model) }} - {{ model.name }} - {{ $t('input.remote') }} - - {{ model.parameters.parameter_size }} - -
-
- {{ model.description }} +
+
+
+ +
+
+ {{ modelDisplayName(model) }} + {{ model.name }} +
@@ -3105,41 +3143,49 @@ const getImgSrc = (url: string) => { .model-selector-overlay { position: fixed; inset: 0; - z-index: 9998; + z-index: 9999; background: transparent; touch-action: none; } .model-selector-dropdown { position: fixed !important; - z-index: 9999; - background: var(--td-bg-color-container, #fff); + z-index: 10000; + background: var(--td-bg-color-container); + border: .5px solid var(--td-component-border); border-radius: 10px; - box-shadow: var(--td-shadow-2, 0 6px 28px rgba(15, 23, 42, 0.08)); - border: .5px solid var(--td-component-border, #e7e9eb); + box-shadow: var(--td-shadow-2); overflow: hidden; display: flex; flex-direction: column; margin: 0 !important; padding: 0 !important; transform: none !important; + transform-origin: top left; + animation: modelSelectorFadeIn 0.15s ease-out; +} + +@keyframes modelSelectorFadeIn { + from { opacity: 0; transform: scale(0.98); } + to { opacity: 1; transform: scale(1); } } .model-selector-header { display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; - border-bottom: .5px solid var(--td-component-stroke, #f0f0f0); - background: var(--td-bg-color-container, #fff); + padding: 8px 10px; + border-bottom: .5px solid var(--td-component-stroke); + background: var(--td-bg-color-container); font-size: 12px; font-weight: 500; - color: var(--td-text-color-secondary, #666); + color: var(--td-text-color-secondary); } .model-selector-content { flex: 1; min-height: 0; + max-height: 260px; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; @@ -3151,14 +3197,14 @@ const getImgSrc = (url: string) => { align-items: center; gap: 4px; padding: 2px 8px; - border-radius: 4px; + border-radius: 6px; border: .5px solid transparent; background: transparent; - color: var(--td-brand-color, #07c05f); + color: var(--td-brand-color); font-size: 12px; font-weight: 500; cursor: pointer; - transition: all 0.2s; + transition: all 0.12s; .add-icon { font-size: 14px; @@ -3167,12 +3213,14 @@ const getImgSrc = (url: string) => { } &:hover { - color: var(--td-brand-color-hover, #05a04f); - background: var(--td-bg-color-secondarycontainer, #f3f3f3); + color: var(--td-brand-color-hover); + background: var(--td-bg-color-secondarycontainer); } } .model-option { + display: flex; + align-items: center; padding: 6px 8px; cursor: pointer; transition: background 0.12s; @@ -3183,21 +3231,13 @@ const getImgSrc = (url: string) => { margin-bottom: 0; } - &:hover { - background: var(--td-bg-color-container-hover, #f6f8f7); - } - + &:hover, &.selected { - background: var(--td-brand-color-light, #eefdf5); - - .model-option-name { - color: var(--td-success-color); - font-weight: 600; - } + background: var(--td-bg-color-secondarycontainer); } &.empty { - color: var(--td-text-color-disabled, #9aa0a6); + color: var(--td-text-color-placeholder); cursor: default; text-align: center; padding: 20px 8px; @@ -3208,62 +3248,47 @@ const getImgSrc = (url: string) => { } } -.model-option-main { +.model-option-left { display: flex; align-items: center; - gap: 6px; - margin-bottom: 1px; + gap: 8px; + width: 100%; + min-width: 0; +} + +.model-option-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--td-text-color-secondary); +} + +.model-option-name-wrap { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + flex: 1; } .model-option-name { font-size: 12px; - color: var(--td-text-color-primary, #222); - flex-shrink: 0; + color: var(--td-text-color-primary); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; line-height: 1.4; } .model-option-raw-name { - flex: 1; - min-width: 0; font-size: 11px; - color: var(--td-text-color-placeholder, #b0b6bd); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.model-option-desc { - font-size: 11px; - color: var(--td-text-color-secondary, #8b9196); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-top: 1px; -} - -.model-badge-remote, -.model-badge-local { - display: inline-block; - padding: 1px 5px; - font-size: 10px; - border-radius: 3px; - font-weight: 500; + color: var(--td-text-color-placeholder); flex-shrink: 0; } -.model-badge-remote { - background: rgba(16, 185, 129, 0.1); - color: var(--td-success-color); -} - -.model-badge-local { - background: rgba(139, 145, 150, 0.1); - color: var(--td-text-color-secondary); -} - /* Agent 模式选择下拉菜单 */ .agent-mode-selector-overlay { position: fixed; diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 4ce3d00c2..98c1c8257 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -1120,6 +1120,10 @@ export default { current: 'Current', goToSettings: 'Settings', sharedLabel: 'Shared', + notReadyHint: 'Not ready. Configure: {items}', + notReadyStatus: 'Setup needed', + notReadyMissing: 'Missing: {items}', + configureAction: 'Configure', }, // Built-in agent information builtinInfo: { @@ -2118,13 +2122,13 @@ export default { agentModeDesc: 'Multi-step thinking, deep analysis', agentNotReadyTooltip: 'Agent is not ready. Please finish configuration first.', agentMissingAllowedTools: 'Allowed tools', - agentMissingSummaryModel: 'Chat Model (Summary Model)', - agentMissingRerankModel: 'Rerank Model', + agentMissingSummaryModel: 'Chat model', + agentMissingRerankModel: 'Rerank model', goToSettings: 'Go to settings →', customAgentNotReadyTooltip: 'Agent is not ready. Please finish configuration first.', customAgentNotReadyDetail: 'Agent is not ready. Please configure the following: {reasons}', - customAgentMissingSummaryModel: 'Chat Model (Summary Model)', - customAgentMissingRerankModel: 'Rerank Model', + customAgentMissingSummaryModel: 'Chat model', + customAgentMissingRerankModel: 'Rerank model', goToAgentEditor: 'Go to configure →', agentNotReadyDetail: 'Agent "{agentName}" is not ready. Please configure: {reasons}', builtinAgentNotReadyDetail: 'Built-in agent "{agentName}" is not ready. Please configure: {reasons}', @@ -3972,7 +3976,7 @@ export default { notReady: 'Not Ready', hint: 'Once configuration is complete, the status will change to "Ready". You can then enable Agent mode in the chat.', missingThinkingModel: 'Thinking model', - missingSummaryModel: 'Chat Model (Summary Model)', + missingSummaryModel: 'Chat model', missingRerankModel: 'Rerank model', missingAllowedTools: 'Allowed tools', pleaseConfigure: 'Please configure {items}', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index f3f33261f..e9df51cda 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -2116,6 +2116,10 @@ export default { current: "현재 선택", goToSettings: "설정", sharedLabel: "공유", + notReadyHint: "아직 구성 필요: {items}", + notReadyStatus: "구성 필요", + notReadyMissing: "누락: {items}", + configureAction: "구성", }, // 내장 에이전트 정보 builtinInfo: { @@ -3890,13 +3894,13 @@ export default { agentModeDesc: "ReAct 추론 프레임워크, 다단계 사고", agentNotReadyTooltip: "Agent가 준비되지 않았습니다. 먼저 설정에서 구성을 완료해주세요", agentMissingAllowedTools: "허용된 도구", - agentMissingSummaryModel: "대화 모델 (Summary Model)", - agentMissingRerankModel: "재정렬 모델 (Rerank Model)", + agentMissingSummaryModel: "대화 모델", + agentMissingRerankModel: "재정렬 모델", goToSettings: "설정으로 이동 →", customAgentNotReadyTooltip: "에이전트가 준비되지 않았습니다. 먼저 구성을 완료하세요.", customAgentNotReadyDetail: "에이전트가 준비되지 않았으며 다음을 구성해야 합니다: {reasons}", - customAgentMissingSummaryModel: "대화 모델(요약 모델)", - customAgentMissingRerankModel: "리랭크 모델 (Rerank Model)", + customAgentMissingSummaryModel: "대화 모델", + customAgentMissingRerankModel: "재정렬 모델", goToAgentEditor: "구성으로 이동 →", agentNotReadyDetail: 'Agent "{agentName}"가 준비되지 않았습니다. 다음 항목을 설정해야 합니다: {reasons}', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 564d250be..6ae173009 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1033,7 +1033,11 @@ export default { addNew: 'Add New Agent', current: 'Current', goToSettings: 'Settings', - sharedLabel: 'Shared' + sharedLabel: 'Shared', + notReadyHint: 'Still needs: {items}', + notReadyStatus: 'Setup needed', + notReadyMissing: 'Missing: {items}', + configureAction: 'Configure', }, builtinInfo: { quickAnswer: { @@ -4239,8 +4243,8 @@ export default { agentNotReadyTooltip: 'Agent не готов. Пожалуйста, завершите настройку.', agentNotReadyDetail: 'Agent не готов. Пожалуйста, настройте следующее: {reasons}', agentMissingAllowedTools: 'Разрешённые инструменты', - agentMissingSummaryModel: 'Модель беседы (Summary Model)', - agentMissingRerankModel: 'Модель переранжирования (Rerank Model)', + agentMissingSummaryModel: 'Модель беседы', + agentMissingRerankModel: 'Модель переранжирования', goToSettings: 'Перейти к настройкам →', webSearch: { toggleOn: 'Включить веб-поиск', @@ -4277,8 +4281,8 @@ export default { }, customAgentNotReadyTooltip: 'Agent is not ready. Please finish configuration first.', customAgentNotReadyDetail: 'Agent is not ready. Please configure the following: {reasons}', - customAgentMissingSummaryModel: 'Chat Model (Summary Model)', - customAgentMissingRerankModel: 'Rerank Model', + customAgentMissingSummaryModel: 'Модель беседы', + customAgentMissingRerankModel: 'Модель переранжирования', goToAgentEditor: 'Go to configure →', builtinAgentNotReadyDetail: 'Built-in agent "{agentName}" is not ready. Please configure: {reasons}', builtinAgentSettingName: 'Intelligent Reasoning', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 54f0c5e0e..2c939c415 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2112,6 +2112,10 @@ export default { current: "当前", goToSettings: "设置", sharedLabel: "共享", + notReadyHint: "尚未就绪,还需配置:{items}", + notReadyStatus: "待配置", + notReadyMissing: "缺少 {items}", + configureAction: "去配置", }, // 内置智能体信息 builtinInfo: { @@ -3899,13 +3903,13 @@ export default { agentModeDesc: "多步思考,深度分析", agentNotReadyTooltip: "Agent 未就绪,请先在设置中完成配置", agentMissingAllowedTools: "允许的工具", - agentMissingSummaryModel: "对话模型(Summary Model)", - agentMissingRerankModel: "重排模型(Rerank Model)", + agentMissingSummaryModel: "对话模型", + agentMissingRerankModel: "重排模型", goToSettings: "前往设置 →", customAgentNotReadyTooltip: "智能体未就绪,请先完成配置", customAgentNotReadyDetail: "智能体未就绪,需要配置以下内容:{reasons}", - customAgentMissingSummaryModel: "对话模型(Summary Model)", - customAgentMissingRerankModel: "重排模型(Rerank Model)", + customAgentMissingSummaryModel: "对话模型", + customAgentMissingRerankModel: "重排模型", goToAgentEditor: "前往配置 →", agentNotReadyDetail: "智能体「{agentName}」未就绪,需要配置以下内容:{reasons}", builtinAgentNotReadyDetail: "内置智能体「{agentName}」未就绪,需要配置以下内容:{reasons}", @@ -3970,8 +3974,8 @@ export default { notReady: "未就绪", hint: '配置完成后,Agent 状态将自动变为"可用",此时可在对话界面开启 Agent 模式', missingThinkingModel: "思考模型", - missingSummaryModel: "对话模型(Summary Model)", - missingRerankModel: "Rerank 模型", + missingSummaryModel: "对话模型", + missingRerankModel: "重排模型", missingAllowedTools: "允许的工具", pleaseConfigure: "请配置{items}", goToConfig: "前往配置对话模型", diff --git a/frontend/src/utils/agent-readiness.test.mjs b/frontend/src/utils/agent-readiness.test.mjs new file mode 100644 index 000000000..9ebd75bac --- /dev/null +++ b/frontend/src/utils/agent-readiness.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + agentHasConfiguredChatModel, + agentRequiresRerankModel, + getAgentNotReadyReasonKeys, + resolveAgentNotReadySection, + resolveAgentNotReadyHighlight, +} from './agent-readiness.ts' + +test('does not treat an unrelated built-in chat model as agent configuration', () => { + assert.equal(agentHasConfiguredChatModel( + {}, + [{ id: 'builtin-chat', type: 'KnowledgeQA' }], + ), false) +}) + +test('accepts the chat model explicitly configured on the agent', () => { + assert.equal(agentHasConfiguredChatModel( + { model_id: 'builtin-chat' }, + [{ id: 'builtin-chat', type: 'KnowledgeQA' }], + ), true) +}) + +test('rejects a configured chat model that no longer exists', () => { + assert.equal(agentHasConfiguredChatModel( + { model_id: 'deleted-chat' }, + [{ id: 'builtin-chat', type: 'KnowledgeQA' }], + ), false) +}) + +test('requires rerank when knowledge_search has a knowledge-base scope', () => { + assert.equal(agentRequiresRerankModel({ + kb_selection_mode: 'all', + allowed_tools: ['knowledge_search'], + }), true) +}) + +test('does not require rerank when knowledge bases are disabled', () => { + assert.equal(agentRequiresRerankModel({ + kb_selection_mode: 'none', + allowed_tools: ['knowledge_search'], + }), false) +}) + +test('does not require rerank for wiki-only tools', () => { + assert.equal(agentRequiresRerankModel({ + kb_selection_mode: 'all', + allowed_tools: ['wiki_search', 'wiki_read_page'], + }), false) +}) + +test('matches backend default-tool behavior', () => { + assert.equal(agentRequiresRerankModel({ + kb_selection_mode: 'all', + allowed_tools: [], + }), true) + assert.equal(agentRequiresRerankModel({ + kb_selection_mode: 'none', + allowed_tools: [], + }), false) +}) + +test('resolveAgentNotReadySection opens model config for model issues', () => { + assert.equal(resolveAgentNotReadySection(['summary_model']), 'model') + assert.equal(resolveAgentNotReadySection(['rerank_model']), 'model') + assert.equal(resolveAgentNotReadySection(['summary_model', 'allowed_tools']), 'model') +}) + +test('resolveAgentNotReadySection opens tools config when only tools are missing', () => { + assert.equal(resolveAgentNotReadySection(['allowed_tools']), 'tools') +}) + +test('resolveAgentNotReadyHighlight returns the first missing item', () => { + assert.equal(resolveAgentNotReadyHighlight(['summary_model', 'rerank_model']), 'summary_model') + assert.equal(resolveAgentNotReadyHighlight(['allowed_tools']), 'allowed_tools') + assert.equal(resolveAgentNotReadyHighlight([]), undefined) +}) + +test('getAgentNotReadyReasonKeys flags missing chat model', () => { + assert.deepEqual(getAgentNotReadyReasonKeys( + {}, + [{ id: 'chat-1', type: 'KnowledgeQA' }], + { isAgentMode: false, isSharedAgent: false }, + ), ['summary_model']) +}) + +test('getAgentNotReadyReasonKeys requires rerank only in agent mode with KB search', () => { + assert.deepEqual(getAgentNotReadyReasonKeys( + { kb_selection_mode: 'all', allowed_tools: ['knowledge_search'] }, + [{ id: 'chat-1', type: 'KnowledgeQA' }, { id: 'rerank-1', type: 'Rerank' }], + { isAgentMode: true, isSharedAgent: false }, + ), ['summary_model', 'rerank_model']) +}) diff --git a/frontend/src/utils/agent-readiness.ts b/frontend/src/utils/agent-readiness.ts new file mode 100644 index 000000000..7355ce4c6 --- /dev/null +++ b/frontend/src/utils/agent-readiness.ts @@ -0,0 +1,93 @@ +import type { CustomAgentConfig } from '@/api/agent' +import type { ModelConfig } from '@/api/model' + +export type AgentNotReadyReasonKey = 'summary_model' | 'rerank_model' | 'allowed_tools' + +/** + * An agent is chat-ready only when it explicitly references a usable chat + * model. Merely having some built-in/default model in the tenant must not hide + * incomplete agent configuration. + * + * For shared agents the model belongs to the source tenant, so the caller can + * require a non-empty ID but must leave existence validation to the backend. + */ +export function agentHasConfiguredChatModel( + config: Pick | undefined, + models: Pick[], + sourceModelIsRemote = false, +): boolean { + const modelID = config?.model_id?.trim() + if (!modelID) return false + if (sourceModelIsRemote) return true + return models.some(model => model.type === 'KnowledgeQA' && model.id === modelID) +} + +/** + * Keep this aligned with backend agentRequiresRerankModel. + * + * Rerank is needed only when knowledge_search can actually run. Explicitly + * disabling the knowledge-base scope makes KB tools ineffective, even if an + * older agent configuration still contains knowledge_search in allowed_tools. + */ +export function agentRequiresRerankModel( + config: Pick | undefined, +): boolean { + if (!config || config.kb_selection_mode === 'none') return false + + const allowedTools = config.allowed_tools || [] + // The backend falls back to DefaultAllowedTools when the list is empty, + // and that default includes knowledge_search. + if (allowedTools.length === 0) return true + + return allowedTools.includes('knowledge_search') +} + +export function getAgentNotReadyReasonKeys( + config: Pick< + CustomAgentConfig, + 'model_id' | 'rerank_model_id' | 'kb_selection_mode' | 'allowed_tools' | 'agent_mode' + > | undefined, + models: Pick[], + options: { isAgentMode: boolean; isSharedAgent: boolean }, +): AgentNotReadyReasonKey[] { + const reasons: AgentNotReadyReasonKey[] = [] + + if (!agentHasConfiguredChatModel(config, models, options.isSharedAgent)) { + reasons.push('summary_model') + } + + if (options.isAgentMode && agentRequiresRerankModel(config)) { + const rerankModelID = config?.rerank_model_id?.trim() + const rerankExists = !!rerankModelID && ( + options.isSharedAgent + || models.some(model => model.type === 'Rerank' && model.id === rerankModelID) + ) + if (!rerankExists) { + reasons.push('rerank_model') + } + } + + if (options.isAgentMode && (!config?.allowed_tools || config.allowed_tools.length === 0)) { + reasons.push('allowed_tools') + } + + return reasons +} + +/** Map missing-config reasons to the agent editor section that fixes them. */ +export function resolveAgentNotReadySection(reasons: AgentNotReadyReasonKey[]): string { + if (reasons.includes('summary_model') || reasons.includes('rerank_model')) { + return 'model' + } + if (reasons.includes('allowed_tools')) { + return 'tools' + } + return 'model' +} + +/** First missing item — used to highlight the corresponding editor field after navigation. */ +export function resolveAgentNotReadyHighlight( + reasons: AgentNotReadyReasonKey[], +): AgentNotReadyReasonKey | undefined { + return reasons[0] +} diff --git a/frontend/src/views/agent/AgentEditorModal.vue b/frontend/src/views/agent/AgentEditorModal.vue index 0c88c7aa2..c9862c726 100644 --- a/frontend/src/views/agent/AgentEditorModal.vue +++ b/frontend/src/views/agent/AgentEditorModal.vue @@ -35,7 +35,7 @@
-
+
@@ -565,7 +565,12 @@
-
+

{{ $t('agentEditor.desc.model') }}

@@ -616,7 +621,12 @@
-
+