feat(web/config): 重构配置侧边栏为通用选择组件

重构 AgentConfigSidebar 组件,将工具选择逻辑抽象为通用选择组件
- 支持显示选项名称和描述
- 根据选项数量自动切换显示模式(卡片列表或弹窗)
- 统一处理工具、知识库等多选配置项
- 优化样式和交互体验
This commit is contained in:
Wenjie Zhang 2026-01-22 11:40:09 +08:00
parent 5cb6e4402f
commit 7ffc7a7920
7 changed files with 248 additions and 169 deletions

View File

@ -20,7 +20,9 @@ class ChatbotAgent(BaseAgent):
async def get_graph(self, **kwargs): async def get_graph(self, **kwargs):
"""构建图""" """构建图"""
context = self.context_schema() context = self.context_schema()
all_mcp_tools = await get_tools_from_all_servers() # 因为异步加载,无法放在 RuntimeConfigMiddleware 的 __init__ 中 all_mcp_tools = (
await get_tools_from_all_servers()
) # 因为异步加载,无法放在 RuntimeConfigMiddleware 的 __init__ 中
# 使用 create_agent 创建智能体 # 使用 create_agent 创建智能体
# 注意tools 参数由 RuntimeConfigMiddleware 在 wrap_model_call 中动态设置 # 注意tools 参数由 RuntimeConfigMiddleware 在 wrap_model_call 中动态设置

View File

@ -75,7 +75,9 @@ class BaseContext:
default_factory=list, default_factory=list,
metadata={ metadata={
"name": "知识库", "name": "知识库",
"options": lambda: [k["name"] for k in knowledge_base.get_retrievers().values()], "options": lambda: [
{"name": k["name"], "description": k["description"]} for k in knowledge_base.get_retrievers().values()
],
"description": "知识库列表,可以在左侧知识库页面中创建知识库。", "description": "知识库列表,可以在左侧知识库页面中创建知识库。",
"type": "list", # Explicitly mark as list type for frontend if needed "type": "list", # Explicitly mark as list type for frontend if needed
}, },

View File

@ -5,10 +5,10 @@ from typing import Any
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
from src.utils.logging_config import logger
from src.agents.common import load_chat_model from src.agents.common import load_chat_model
from src.agents.common.tools import get_kb_based_tools, get_buildin_tools from src.agents.common.tools import get_kb_based_tools
from src.services.mcp_service import get_enabled_mcp_tools from src.services.mcp_service import get_enabled_mcp_tools
from src.utils.logging_config import logger
def _is_system_message(msg: Any) -> bool: def _is_system_message(msg: Any) -> bool:
@ -82,7 +82,6 @@ class RuntimeConfigMiddleware(AgentMiddleware):
request = request.override(model=model, tools=enabled_tools, messages=messages) request = request.override(model=model, tools=enabled_tools, messages=messages)
return await handler(request) return await handler(request)
async def get_tools_from_context(self, context) -> list: async def get_tools_from_context(self, context) -> list:
"""从上下文配置中获取工具列表""" """从上下文配置中获取工具列表"""
# 1. 基础工具 (从 context.tools 中筛选) # 1. 基础工具 (从 context.tools 中筛选)

View File

@ -4,7 +4,12 @@ from deepagents.middleware.filesystem import FilesystemMiddleware
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
from deepagents.middleware.subagents import SubAgentMiddleware from deepagents.middleware.subagents import SubAgentMiddleware
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelRetryMiddleware, SummarizationMiddleware, TodoListMiddleware, dynamic_prompt from langchain.agents.middleware import (
ModelRequest,
SummarizationMiddleware,
TodoListMiddleware,
dynamic_prompt,
)
from src.agents.common import BaseAgent, load_chat_model from src.agents.common import BaseAgent, load_chat_model
from src.agents.common.middlewares import RuntimeConfigMiddleware, inject_attachment_context from src.agents.common.middlewares import RuntimeConfigMiddleware, inject_attachment_context

View File

@ -2,7 +2,6 @@ from dataclasses import dataclass, field
from typing import Annotated from typing import Annotated
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import ModelRetryMiddleware
from src.agents.common import BaseAgent, BaseContext, load_chat_model from src.agents.common import BaseAgent, BaseContext, load_chat_model
from src.agents.common.middlewares import ( from src.agents.common.middlewares import (

View File

@ -7,7 +7,8 @@ from collections.abc import AsyncIterator
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
from langgraph.types import Command from langgraph.types import Command
from src import config as conf, knowledge_base from src import config as conf
from src import knowledge_base
from src.agents import agent_manager from src.agents import agent_manager
from src.plugins.guard import content_guard from src.plugins.guard import content_guard
from src.repositories.agent_config_repository import AgentConfigRepository from src.repositories.agent_config_repository import AgentConfigRepository
@ -307,7 +308,6 @@ async def stream_agent_chat(
"agent_config": agent_config, "agent_config": agent_config,
} }
try: try:
conv_repo = ConversationRepository(db) conv_repo = ConversationRepository(db)
@ -347,9 +347,7 @@ async def stream_agent_chat(
filtered_knowledge_names = [kb for kb in requested_knowledge_names if kb in accessible_kb_names] filtered_knowledge_names = [kb for kb in requested_knowledge_names if kb in accessible_kb_names]
blocked_knowledge_names = [kb for kb in requested_knowledge_names if kb not in accessible_kb_names] blocked_knowledge_names = [kb for kb in requested_knowledge_names if kb not in accessible_kb_names]
if blocked_knowledge_names: if blocked_knowledge_names:
logger.warning( logger.warning(f"用户 {user_id} 无权访问知识库: {blocked_knowledge_names}, 已自动过滤")
f"用户 {user_id} 无权访问知识库: {blocked_knowledge_names}, 已自动过滤"
)
input_context["agent_config"]["knowledges"] = filtered_knowledge_names input_context["agent_config"]["knowledges"] = filtered_knowledge_names
full_msg = None full_msg = None

View File

@ -57,7 +57,10 @@
</div> </div>
<!-- 系统提示词 --> <!-- 系统提示词 -->
<div v-else-if="value.template_metadata.kind === 'prompt'" class="system-prompt-container"> <div
v-else-if="value.template_metadata.kind === 'prompt'"
class="system-prompt-container"
>
<!-- 编辑模式 --> <!-- 编辑模式 -->
<a-textarea <a-textarea
v-if="systemPromptEditMode" v-if="systemPromptEditMode"
@ -139,43 +142,89 @@
</a-select-option> </a-select-option>
</a-select> </a-select>
<!-- 多选 --> <!-- 多选 / 工具列表 (统一处理) -->
<div <div v-else-if="isListConfig(key, value)" class="list-config-container">
v-else-if="value?.options.length > 0 && value?.type === 'list'" <!-- Case 1: <= 5 options, inline list -->
class="multi-select-cards" <div v-if="getConfigOptions(value).length <= 5" class="multi-select-cards">
> <div class="multi-select-label">
<div class="multi-select-label"> <span>已选择 {{ getSelectedCount(key) }} </span>
<span>已选择 {{ getSelectedCount(key) }} </span> <a-button
<a-button type="link"
type="link" size="small"
size="small" class="clear-btn"
class="clear-btn" @click="clearSelection(key)"
@click="clearSelection(key)" v-if="getSelectedCount(key) > 0"
v-if="getSelectedCount(key) > 0" >
> 清空
清空 </a-button>
</a-button> </div>
</div> <div class="options-grid">
<div class="options-grid"> <div
<div v-for="option in getConfigOptions(value)"
v-for="option in value.options" :key="getOptionValue(option)"
:key="option" class="option-card"
class="option-card" :class="{
:class="{ selected: isOptionSelected(key, getOptionValue(option)),
selected: isOptionSelected(key, option), unselected: !isOptionSelected(key, getOptionValue(option))
unselected: !isOptionSelected(key, option) }"
}" @click="toggleOption(key, getOptionValue(option))"
@click="toggleOption(key, option)" >
> <div class="option-content">
<div class="option-content"> <span class="option-text">{{ getOptionLabel(option) }}</span>
<span class="option-text">{{ option }}</span> <div class="option-indicator">
<div class="option-indicator"> <Check
<Check v-if="isOptionSelected(key, option)" :size="16" /> v-if="isOptionSelected(key, getOptionValue(option))"
<Plus v-else :size="16" /> :size="16"
/>
<Plus v-else :size="16" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Case 2: > 5 options, Modal trigger -->
<div v-else class="selection-container">
<div class="selection-summary">
<div class="selection-summary-info">
<span class="selection-count">已选择 {{ getSelectedCount(key) }} </span>
<a-button
type="link"
size="small"
class="clear-btn"
@click="clearSelection(key)"
v-if="getSelectedCount(key) > 0"
>
清空
</a-button>
</div>
<a-button
type="primary"
size="small"
class="selection-trigger-btn"
@click="openSelectionModal(key)"
>
选择...
</a-button>
</div>
<!-- Selected Preview Tags -->
<div v-if="getSelectedCount(key) > 0" class="selection-preview">
<a-tag
v-for="val in agentConfig[key]"
:key="val"
closable
@close="toggleOption(key, val)"
class="selection-tag"
>
{{ getOptionLabelFromValue(key, val) }}
</a-tag>
</div>
</div>
</div> </div>
<!-- 数字 --> <!-- 数字 -->
@ -251,20 +300,21 @@
</div> </div>
</div> </div>
<!-- 工具选择弹窗 --> <!-- 通用选择弹窗 -->
<a-modal <a-modal
v-model:open="toolsModalOpen" v-model:open="selectionModalOpen"
title="选择工具" :title="`选择${configurableItems[currentConfigKey]?.name || '项目'}`"
:width="800" :width="800"
:footer="null" :footer="null"
:maskClosable="false" :maskClosable="false"
class="tools-modal" class="selection-modal"
> >
<div class="tools-modal-content"> <div class="selection-modal-content">
<div class="tools-search"> <div class="selection-search">
<a-input <a-input
v-model:value="toolsSearchText" v-model:value="selectionSearchText"
placeholder="搜索工具..." placeholder="搜索..."
allow-clear allow-clear
class="search-input" class="search-input"
> >
@ -274,32 +324,39 @@
</a-input> </a-input>
</div> </div>
<div class="tools-list"> <div class="selection-list">
<div <div
v-for="tool in filteredTools" v-for="option in filteredOptions"
:key="tool.id" :key="getOptionValue(option)"
class="tool-item" class="selection-item"
:class="{ selected: selectedTools.includes(tool.id) }" :class="{ selected: tempSelectedValues.includes(getOptionValue(option)) }"
@click="toggleToolSelection(tool.id)" @click="toggleModalSelection(getOptionValue(option))"
> >
<div class="tool-content"> <div class="selection-item-content">
<div class="tool-header"> <div class="selection-item-header">
<span class="tool-name">{{ tool.name }}</span> <span class="selection-item-name">{{ getOptionLabel(option) }}</span>
<div class="tool-indicator">
<Check v-if="selectedTools.includes(tool.id)" :size="16" /> <div class="selection-item-indicator">
<Check v-if="tempSelectedValues.includes(getOptionValue(option))" :size="16" />
<Plus v-else :size="16" /> <Plus v-else :size="16" />
</div> </div>
</div> </div>
<div class="tool-description">{{ tool.description }}</div>
<div v-if="getOptionDescription(option)" class="selection-item-description">
{{ getOptionDescription(option) }}
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="tools-modal-footer"> <div class="selection-modal-footer">
<div class="selected-count">已选择 {{ selectedTools.length }} 个工具</div> <div class="selected-count">已选择 {{ tempSelectedValues.length }} </div>
<div class="modal-actions"> <div class="modal-actions">
<a-button @click="cancelToolsSelection">取消</a-button> <a-button @click="closeSelectionModal">取消</a-button>
<a-button type="primary" @click="confirmToolsSelection">确认</a-button>
<a-button type="primary" @click="confirmSelection">确认</a-button>
</div> </div>
</div> </div>
</div> </div>
@ -308,16 +365,16 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, nextTick } from 'vue'
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { X, Save, Trash2, Check, Plus, Search, Star } from 'lucide-vue-next' import { X, Trash2, Check, Plus, Search, Star } from 'lucide-vue-next'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue' import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import { useAgentStore } from '@/stores/agent' import { useAgentStore } from '@/stores/agent'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
// Props // Props
const props = defineProps({ defineProps({
isOpen: { isOpen: {
type: Boolean, type: Boolean,
default: false default: false
@ -348,9 +405,10 @@ const {
// console.log(availableTools.value) // console.log(availableTools.value)
// //
const toolsModalOpen = ref(false) const selectionModalOpen = ref(false)
const selectedTools = ref([]) const currentConfigKey = ref(null)
const toolsSearchText = ref('') const tempSelectedValues = ref([])
const selectionSearchText = ref('')
const systemPromptEditMode = ref(false) const systemPromptEditMode = ref(false)
const activeTab = ref('basic') const activeTab = ref('basic')
@ -367,8 +425,9 @@ const isDeletingConfig = ref(false)
const hasOtherConfigs = computed(() => { const hasOtherConfigs = computed(() => {
if (isEmptyConfig.value) return false if (isEmptyConfig.value) return false
return Object.entries(configurableItems.value).some(([key, value]) => { return Object.entries(configurableItems.value).some(([, value]) => {
const isBasic = value.template_metadata?.kind === 'prompt' || value.template_metadata?.kind === 'llm' const isBasic =
value.template_metadata?.kind === 'prompt' || value.template_metadata?.kind === 'llm'
const isTools = const isTools =
value.template_metadata?.kind === 'mcps' || value.template_metadata?.kind === 'mcps' ||
value.template_metadata?.kind === 'knowledges' || value.template_metadata?.kind === 'knowledges' ||
@ -391,22 +450,61 @@ const segmentedOptions = computed(() => {
return options return options
}) })
const filteredTools = computed(() => { //
const toolsList = availableTools.value ? Object.values(availableTools.value) : [] const getConfigOptions = (value) => {
if (!toolsSearchText.value) { if (value?.template_metadata?.kind === 'tools') {
return toolsList return availableTools.value ? Object.values(availableTools.value) : []
} }
const searchLower = toolsSearchText.value.toLowerCase() return value?.options || []
return toolsList.filter( }
(tool) =>
tool.name.toLowerCase().includes(searchLower) || const isListConfig = (key, value) => {
tool.description.toLowerCase().includes(searchLower) const isTools = value?.template_metadata?.kind === 'tools'
) const isList = value?.type === 'list'
return isTools || isList
}
const getOptionValue = (option) => {
if (typeof option === 'object' && option !== null) {
return option.id || option.value || option.name
}
return option
}
const getOptionLabel = (option) => {
if (typeof option === 'object' && option !== null) {
return option.name || option.label || option.id
}
return option
}
const getOptionDescription = (option) => {
if (typeof option === 'object' && option !== null) {
return option.description || '暂无描述'
}
return null
}
const filteredOptions = computed(() => {
if (!currentConfigKey.value) return []
const key = currentConfigKey.value
const configItem = configurableItems.value[key]
const options = getConfigOptions(configItem)
if (!selectionSearchText.value) return options
const search = selectionSearchText.value.toLowerCase()
return options.filter((opt) => {
const label = String(getOptionLabel(opt)).toLowerCase()
const desc = String(getOptionDescription(opt) || '').toLowerCase()
return label.includes(search) || desc.includes(search)
})
}) })
// //
const shouldShowConfig = (key, value) => { const shouldShowConfig = (key, value) => {
const isBasic = value.template_metadata?.kind === 'prompt' || value.template_metadata?.kind === 'llm' const isBasic =
value.template_metadata?.kind === 'prompt' || value.template_metadata?.kind === 'llm'
const isTools = const isTools =
value.template_metadata?.kind === 'mcps' || value.template_metadata?.kind === 'mcps' ||
value.template_metadata?.kind === 'knowledges' || value.template_metadata?.kind === 'knowledges' ||
@ -488,60 +586,51 @@ const clearSelection = (key) => {
}) })
} }
// //
const getToolNameById = (toolId) => { const getOptionLabelFromValue = (key, val) => {
const toolsList = availableTools.value ? Object.values(availableTools.value) : [] const options = getConfigOptions(configurableItems.value[key])
const tool = toolsList.find((t) => t.id === toolId) const option = options.find((opt) => getOptionValue(opt) === val)
return tool ? tool.name : toolId return option ? getOptionLabel(option) : val
} }
const openToolsModal = async () => { const openSelectionModal = async (key) => {
console.log('availableTools.value', availableTools.value) currentConfigKey.value = key
try { //
// if (configurableItems.value[key]?.template_metadata?.kind === 'tools' && selectedAgentId.value) {
if (selectedAgentId.value) { try {
await agentStore.fetchAgentDetail(selectedAgentId.value, true) await agentStore.fetchAgentDetail(selectedAgentId.value, true)
} catch (error) {
console.error('刷新工具列表失败:', error)
} }
selectedTools.value = [...(agentConfig.value?.tools || [])]
toolsModalOpen.value = true
} catch (error) {
console.error('打开工具选择弹窗失败:', error)
message.error('打开工具选择弹窗失败')
} }
const currentValues = agentConfig.value[key] || []
tempSelectedValues.value = [...currentValues]
selectionModalOpen.value = true
} }
const toggleToolSelection = (toolId) => { const toggleModalSelection = (optionValue) => {
const index = selectedTools.value.indexOf(toolId) const index = tempSelectedValues.value.indexOf(optionValue)
if (index > -1) { if (index > -1) {
selectedTools.value.splice(index, 1) tempSelectedValues.value.splice(index, 1)
} else { } else {
selectedTools.value.push(toolId) tempSelectedValues.value.push(optionValue)
} }
} }
const removeSelectedTool = (toolId) => { const confirmSelection = () => {
const currentTools = [...(agentConfig.value?.tools || [])] if (currentConfigKey.value) {
const index = currentTools.indexOf(toolId)
if (index > -1) {
currentTools.splice(index, 1)
agentStore.updateAgentConfig({ agentStore.updateAgentConfig({
tools: currentTools [currentConfigKey.value]: [...tempSelectedValues.value]
}) })
} }
closeSelectionModal()
} }
const confirmToolsSelection = () => { const closeSelectionModal = () => {
agentStore.updateAgentConfig({ selectionModalOpen.value = false
tools: [...selectedTools.value] currentConfigKey.value = null
}) tempSelectedValues.value = []
toolsModalOpen.value = false selectionSearchText.value = ''
toolsSearchText.value = ''
}
const cancelToolsSelection = () => {
toolsModalOpen.value = false
toolsSearchText.value = ''
selectedTools.value = []
} }
// //
@ -663,21 +752,6 @@ const confirmDeleteConfig = async () => {
} }
}) })
} }
const resetConfig = async () => {
if (!selectedAgentId.value) {
message.error('没有选择智能体')
return
}
try {
agentStore.resetAgentConfig()
message.info('配置已重置')
} catch (error) {
console.error('重置配置出错:', error)
message.error('重置配置失败')
}
}
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -939,9 +1013,9 @@ const resetConfig = async () => {
} }
} }
// //
.tools-selector { .selection-container {
.tools-summary { .selection-summary {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -951,20 +1025,20 @@ const resetConfig = async () => {
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
margin-bottom: 8px; margin-bottom: 8px;
.tools-summary-info { .selection-summary-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
color: var(--gray-900); color: var(--gray-900);
.tools-count { .selection-count {
color: var(--gray-900); color: var(--gray-900);
font-weight: 500; font-weight: 500;
} }
} }
.select-tools-btn { .selection-trigger-btn {
background: var(--main-color); background: var(--main-color);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
@ -979,15 +1053,15 @@ const resetConfig = async () => {
} }
} }
.selected-tools-preview { .selection-preview {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
.tool-tag { .selection-tag {
margin: 0; margin: 0;
padding: 4px 8px; padding: 4px 8px;
border-radius: 12px; border-radius: 8px;
background: var(--gray-50); background: var(--gray-50);
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
color: var(--gray-900); color: var(--gray-900);
@ -1078,10 +1152,10 @@ const resetConfig = async () => {
} }
} }
// //
.tools-modal { .selection-modal {
.tools-modal-content { .selection-modal-content {
.tools-search { .selection-search {
margin-bottom: 16px; margin-bottom: 16px;
.search-input { .search-input {
@ -1112,7 +1186,7 @@ const resetConfig = async () => {
} }
} }
.tools-list { .selection-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px; gap: 12px;
@ -1144,7 +1218,7 @@ const resetConfig = async () => {
background: var(--gray-500); background: var(--gray-500);
} }
.tool-item { .selection-item {
padding: 12px 16px; padding: 12px 16px;
border-bottom: none; border-bottom: none;
cursor: pointer; cursor: pointer;
@ -1158,14 +1232,13 @@ const resetConfig = async () => {
border-color: var(--gray-300); border-color: var(--gray-300);
background: var(--gray-20); background: var(--gray-20);
} }
.tool-content { .selection-item-content {
.tool-header { .selection-item-header {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 6px;
gap: 8px; gap: 8px;
.tool-name { .selection-item-name {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--gray-900); color: var(--gray-900);
@ -1173,7 +1246,7 @@ const resetConfig = async () => {
flex: 1; flex: 1;
} }
.tool-indicator { .selection-item-indicator {
color: var(--gray-400); color: var(--gray-400);
font-size: 16px; font-size: 16px;
transition: all 0.2s ease; transition: all 0.2s ease;
@ -1181,10 +1254,11 @@ const resetConfig = async () => {
} }
} }
.tool-description { .selection-item-description {
font-size: 12px; font-size: 12px;
color: var(--gray-600); color: var(--gray-600);
line-height: 1.4; line-height: 1.4;
margin-top: 6px;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@ -1194,25 +1268,25 @@ const resetConfig = async () => {
} }
&.selected { &.selected {
background: var(--main-50); background: var(--main-10);
border-color: var(--main-200); border-color: var(--main-color);
.tool-content { .selection-item-content {
.tool-name { .selection-item-name {
color: var(--main-800); color: var(--main-800);
} }
.tool-indicator { .selection-item-indicator {
color: var(--main-800); color: var(--main-800);
} }
} }
.tool-description { .selection-item-description {
color: var(--gray-900); color: var(--gray-900);
} }
} }
} }
} }
.tools-modal-footer { .selection-modal-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;