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 @@
-
+