feat(web): 在聊天输入中实现并优化 @ 提及功能

- 在 MessageInputComponent 中启用 @ 提及触发器,并支持键盘导航。
- 通过 AgentChatComponent 将提及配置传递到 AgentInputArea,最终传递至输入组件。
- 实现根据所选代理配置获取并筛选知识库和 MCP 的逻辑。
- 在提及候选列表中包含标准化的代理文件和上传的附件。
- 通过向上展开的弹窗、键盘选择时自动滚动以及自动插入空格来提升用户体验。
- 优化提及弹窗样式(宽度、字体大小、间距),移除不必要的图标以保持界面简洁。
This commit is contained in:
Wenjie Zhang 2026-02-04 17:05:26 +08:00
parent 5348106513
commit 34b06b297e
5 changed files with 631 additions and 5 deletions

View File

@ -11,6 +11,7 @@ export * from './agent_api' // 智能体API
export * from './tasker' // 任务管理API
export * from './mindmap_api' // 思维导图API
export * from './department_api' // 部门管理API
export * from './mcp_api' // MCP API
// 导出基础工具函数
export {

View File

@ -136,6 +136,7 @@
:ensure-thread="ensureActiveThread"
:has-state-content="hasAgentStateContent"
:is-panel-open="isAgentPanelOpen"
:mention="mentionConfig"
@send="handleSendOrStop"
@attachment-changed="handleAgentStateRefresh"
@toggle-panel="toggleAgentPanel"
@ -209,7 +210,7 @@ import { useAgentStore } from '@/stores/agent'
import { useChatUIStore } from '@/stores/chatUI'
import { storeToRefs } from 'pinia'
import { MessageProcessor } from '@/utils/messageProcessor'
import { agentApi, threadApi } from '@/apis'
import { agentApi, threadApi, databaseApi, mcpApi } from '@/apis'
import HumanApprovalModal from '@/components/HumanApprovalModal.vue'
import { useApproval } from '@/composables/useApproval'
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
@ -225,7 +226,14 @@ const emit = defineEmits(['open-config', 'open-agent-modal'])
// ==================== STORE MANAGEMENT ====================
const agentStore = useAgentStore()
const chatUIStore = useChatUIStore()
const { agents, selectedAgentId, defaultAgentId, selectedAgentConfigId } = storeToRefs(agentStore)
const {
agents,
selectedAgentId,
defaultAgentId,
selectedAgentConfigId,
agentConfig,
configurableItems
} = storeToRefs(agentStore)
// ==================== LOCAL CHAT & UI STATE ====================
const userInput = ref('')
@ -268,6 +276,10 @@ const localUIState = reactive({
isInitialRender: true
})
// Mention resources
const availableKnowledgeBases = ref([])
const availableMcps = ref([])
// Agent Panel State
const isAgentPanelOpen = ref(false)
const isResizing = ref(false)
@ -343,6 +355,66 @@ const hasAgentStateContent = computed(() => {
return todoCount > 0 || fileCount > 0 || attachmentCount > 0
})
const mentionConfig = computed(() => {
const rawFiles = currentAgentState.value?.files || []
const rawAttachments = currentAgentState.value?.attachments || []
const files = []
if (Array.isArray(rawFiles)) {
rawFiles.forEach((item) => {
if (typeof item === 'object' && item !== null) {
Object.entries(item).forEach(([filePath, fileData]) => {
files.push({
path: filePath,
...fileData
})
})
}
})
}
if (Array.isArray(rawAttachments)) {
rawAttachments.forEach((item) => {
if (item && item.file_name) {
files.push({
path: item.file_name,
...item
})
}
})
}
// Filter KBs and MCPs based on agent config
const configItems = configurableItems.value || {}
const currentConfig = agentConfig.value || {}
const allowedKbNames = new Set()
const allowedMcpNames = new Set()
Object.entries(configItems).forEach(([key, item]) => {
const kind = item?.template_metadata?.kind
const val = currentConfig[key]
if (Array.isArray(val)) {
if (kind === 'knowledges') {
val.forEach((v) => allowedKbNames.add(v))
} else if (kind === 'mcps') {
val.forEach((v) => allowedMcpNames.add(v))
}
}
})
const knowledgeBases = availableKnowledgeBases.value.filter((kb) => allowedKbNames.has(kb.name))
const mcps = availableMcps.value.filter((mcp) => allowedMcpNames.has(mcp.name))
if (!files.length && !knowledgeBases.length && !mcps.length) return null
return {
files,
knowledgeBases,
mcps
}
})
const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || [])
// Refs
@ -595,6 +667,19 @@ const fetchAgentState = async (agentId, threadId) => {
} catch (error) {}
}
const fetchMentionResources = async () => {
try {
const [dbsRes, mcpsRes] = await Promise.all([
databaseApi.getAccessibleDatabases().catch(() => ({ databases: [] })),
mcpApi.getMcpServers().catch(() => ({ data: [] }))
])
availableKnowledgeBases.value = dbsRes.databases || []
availableMcps.value = mcpsRes.data || []
} catch (e) {
console.warn('Failed to fetch mention resources', e)
}
}
const ensureActiveThread = async (title = '新的对话') => {
if (currentChatId.value) return currentChatId.value
try {
@ -1096,6 +1181,7 @@ const initAll = async () => {
if (!agentStore.isInitialized) {
await agentStore.initialize()
}
await fetchMentionResources()
} catch (error) {
handleChatError(error, 'load')
}

View File

@ -8,6 +8,7 @@
:send-button-disabled="sendButtonDisabled"
:placeholder="placeholder"
:force-multi-line="hasStateContent"
:mention="mention"
@send="handleSend"
@keydown="handleKeyDown"
>
@ -68,7 +69,8 @@ const props = defineProps({
threadId: { type: String, default: null },
ensureThread: { type: Function, required: true },
hasStateContent: { type: Boolean, default: false },
isPanelOpen: { type: Boolean, default: false }
isPanelOpen: { type: Boolean, default: false },
mention: { type: Object, default: () => null }
})
const emit = defineEmits([

View File

@ -34,12 +34,65 @@
class="user-input"
:value="inputValue"
@keydown="handleKeyPress"
@keyup="handleKeyUp"
@input="handleInput"
@focus="focusInput"
:placeholder="placeholder"
:disabled="disabled"
/>
<!-- @ 提及选择弹窗 -->
<div
v-if="mentionPopupVisible"
ref="mentionDropdownRef"
class="mention-dropdown-wrapper"
:style="mentionDropdownStyle"
>
<div class="mention-popup">
<!-- 文件列表 -->
<div v-if="mentionItems.files.length > 0" class="mention-group">
<div class="mention-group-title">文件</div>
<div
v-for="(item, index) in mentionItems.files"
:key="'file-' + item.value"
:class="['mention-item', { active: isItemSelected('file', index) }]"
@click="insertMention(item)"
>
{{ item.label }}
</div>
</div>
<!-- 知识库列表 -->
<div v-if="mentionItems.knowledgeBases.length > 0" class="mention-group">
<div class="mention-group-title">知识库</div>
<div
v-for="(item, index) in mentionItems.knowledgeBases"
:key="'kb-' + item.value"
:class="['mention-item', { active: isItemSelected('knowledge', index) }]"
@click="insertMention(item)"
>
{{ item.label }}
</div>
</div>
<!-- MCP 列表 -->
<div v-if="mentionItems.mcps.length > 0" class="mention-group">
<div class="mention-group-title">MCP</div>
<div
v-for="(item, index) in mentionItems.mcps"
:key="'mcp-' + item.value"
:class="['mention-item', { active: isItemSelected('mcp', index) }]"
@click="insertMention(item)"
>
{{ item.label }}
</div>
</div>
<!-- 无结果 -->
<div v-if="!hasAnyItems" class="mention-empty">暂无可引用的项</div>
</div>
</div>
<div class="send-button-container">
<slot name="actions-right"></slot>
<a-tooltip :title="isLoading ? '停止回答' : ''">
@ -63,7 +116,16 @@
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch, onBeforeUnmount, useSlots } from 'vue'
import {
ref,
computed,
onMounted,
nextTick,
watch,
onBeforeUnmount,
useSlots,
onUnmounted
} from 'vue'
import {
SendOutlined,
ArrowUpOutlined,
@ -72,6 +134,15 @@ import {
PlusOutlined
} from '@ant-design/icons-vue'
//
const mentionDropdownRef = ref(null)
const closeMentionPopup = (e) => {
if (!mentionPopupVisible.value) return
if (inputRef.value?.contains(e.target)) return
if (mentionDropdownRef.value?.contains(e.target)) return
mentionPopupVisible.value = false
}
const inputRef = ref(null)
const optionsExpanded = ref(false)
const singleLineHeight = ref(0) // Add this
@ -113,12 +184,213 @@ const props = defineProps({
forceMultiLine: {
type: Boolean,
default: false
},
mention: {
type: Object,
default: () => null
}
})
const emit = defineEmits(['update:modelValue', 'send', 'keydown'])
const slots = useSlots()
// @
const mentionEnabled = computed(() => {
if (!props.mention) return false
const { files, knowledgeBases, mcps } = props.mention
return (
(Array.isArray(files) && files.length > 0) ||
(Array.isArray(knowledgeBases) && knowledgeBases.length > 0) ||
(Array.isArray(mcps) && mcps.length > 0)
)
})
// @
const checkMentionTrigger = (textarea) => {
console.log(
'[Mention] checkMentionTrigger called, textarea:',
!!textarea,
'mentionEnabled:',
mentionEnabled.value
)
if (!textarea || !mentionEnabled.value) return false
const cursorPos = textarea.selectionStart
const textBeforeCursor = inputValue.value.slice(0, cursorPos)
console.log('[Mention] textBeforeCursor:', JSON.stringify(textBeforeCursor))
// @ @ @
const atMatch = textBeforeCursor.match(/@(\S*)$/)
console.log('[Mention] atMatch:', atMatch)
if (atMatch) {
mentionQuery.value = atMatch[1]
mentionPopupVisible.value = true
mentionSelectedIndex.value = 0
updateMentionItems(mentionQuery.value)
console.log('[Mention] popup should be visible now')
return true
}
mentionPopupVisible.value = false
return false
}
//
const updateMentionItems = (query = '') => {
if (!props.mention) {
mentionItems.value = { files: [], knowledgeBases: [], mcps: [] }
return
}
const lowerQuery = query.toLowerCase()
const { files = [], knowledgeBases = [], mcps = [] } = props.mention
const filter = (list) =>
list.filter((item) => {
const label = item.path || item.name || ''
return label.toLowerCase().includes(lowerQuery)
})
mentionItems.value = {
files: filter(files).map((f) => ({
value: f.path,
label: f.path.split('/').pop() || f.path,
type: 'file',
description: f.path
})),
knowledgeBases: filter(knowledgeBases).map((kb) => ({
value: kb.name,
label: kb.name,
type: 'knowledge',
description: kb.db_id
})),
mcps: filter(mcps).map((m) => ({
value: m.name,
label: m.name,
type: 'mcp',
description: m.description || ''
}))
}
}
//
const isItemSelected = (type, index) => {
if (mentionSelectedIndex.value < 0) return false
const filesLen = mentionItems.value.files.length
const kbLen = mentionItems.value.knowledgeBases.length
if (type === 'file') {
return mentionSelectedIndex.value === index
} else if (type === 'knowledge') {
return mentionSelectedIndex.value === filesLen + index
} else {
return mentionSelectedIndex.value === filesLen + kbLen + index
}
}
//
const hasAnyItems = computed(() => {
const items = mentionItems.value
return items.files.length > 0 || items.knowledgeBases.length > 0 || items.mcps.length > 0
})
//
const mentionDropdownStyle = computed(() => {
if (!inputRef.value) return {}
const textarea = inputRef.value
const rect = textarea.getBoundingClientRect()
const parentRect = textarea.parentElement.getBoundingClientRect()
return {
position: 'absolute',
bottom: `${parentRect.bottom - rect.top + 4}px`,
left: `${rect.left - parentRect.left}px`,
zIndex: 1000
}
})
const insertMention = (item) => {
if (!inputRef.value) return
const textarea = inputRef.value
const cursorPos = textarea.selectionStart
const textBeforeCursor = inputValue.value.slice(0, cursorPos)
// @
const newTextBefore = textBeforeCursor.replace(/@(\S*)$/, `@${item.value} `)
const textAfterCursor = inputValue.value.slice(cursorPos)
const newValue = newTextBefore + textAfterCursor
emit('update:modelValue', newValue)
//
nextTick(() => {
const newCursorPos = newTextBefore.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
})
mentionPopupVisible.value = false
mentionQuery.value = ''
}
//
const scrollToItem = (index) => {
nextTick(() => {
const popup = mentionDropdownRef.value?.querySelector('.mention-popup')
if (!popup) return
// mention-item
const items = popup.querySelectorAll('.mention-item')
const selectedItem = items[index]
if (selectedItem) {
//
const popupRect = popup.getBoundingClientRect()
const itemRect = selectedItem.getBoundingClientRect()
if (itemRect.bottom > popupRect.bottom) {
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
} else if (itemRect.top < popupRect.top) {
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}
})
}
//
const handleMentionNavigation = (e) => {
if (!mentionPopupVisible.value) return
const allItems = [
...mentionItems.value.files,
...mentionItems.value.knowledgeBases,
...mentionItems.value.mcps
]
const total = allItems.length
if (total === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
mentionSelectedIndex.value = (mentionSelectedIndex.value + 1) % total
scrollToItem(mentionSelectedIndex.value)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
mentionSelectedIndex.value = (mentionSelectedIndex.value - 1 + total) % total
scrollToItem(mentionSelectedIndex.value)
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (mentionSelectedIndex.value >= 0 && mentionSelectedIndex.value < total) {
e.preventDefault()
insertMention(allItems[mentionSelectedIndex.value])
}
} else if (e.key === 'Escape') {
e.preventDefault()
mentionPopupVisible.value = false
}
}
// Update isSingleLine logic to respect forceMultiLine
const isSingleLineMode = ref(true)
const isSingleLine = computed(() => {
@ -167,13 +439,53 @@ const inputValue = computed({
//
const handleKeyPress = (e) => {
// @
if (mentionPopupVisible.value) {
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key)) {
handleMentionNavigation(e)
return
}
}
emit('keydown', e)
}
// @
const handleKeyUp = (e) => {
if (e.key === '@' && mentionEnabled.value) {
console.log(
'[Mention] @ detected, mentionEnabled:',
mentionEnabled.value,
'mention:',
props.mention
)
nextTick(() => {
checkMentionTrigger(e.target)
})
}
}
//
const handleInput = (e) => {
const value = e.target.value
emit('update:modelValue', value)
// @
if (mentionEnabled.value && !mentionPopupVisible.value) {
const cursorPos = e.target.selectionStart
const textBeforeCursor = value.slice(0, cursorPos)
if (textBeforeCursor.endsWith('@')) {
console.log('[Mention] @ detected via input event')
checkMentionTrigger(e.target)
}
}
//
if (mentionPopupVisible.value) {
nextTick(() => {
checkMentionTrigger(e.target)
})
}
}
//
@ -184,6 +496,12 @@ const handleSendOrStop = () => {
//
const singleLineWidth = ref(0)
// @
const mentionPopupVisible = ref(false)
const mentionQuery = ref('')
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [] })
const mentionSelectedIndex = ref(0)
//
const checkLineCount = () => {
if (!inputRef.value || singleLineHeight.value === 0) {
@ -269,6 +587,7 @@ watch(inputValue, () => {
// Wait for component to mount before setting up onStartTyping
onMounted(() => {
// console.log('Component mounted');
document.addEventListener('click', closeMentionPopup)
nextTick(() => {
if (inputRef.value) {
//
@ -281,11 +600,12 @@ onMounted(() => {
// observeTextareaResize();
})
//
//
onBeforeUnmount(() => {
if (debounceTimer.value) {
clearTimeout(debounceTimer.value)
}
document.removeEventListener('click', closeMentionPopup)
})
//
@ -307,6 +627,7 @@ defineExpose({
box-shadow: 0 2px 8px var(--shadow-1);
transition: all 0.3s ease;
gap: 0px;
position: relative;
/* Default: Multi-line layout with top/bottom slots */
padding: 0.8rem 0.75rem 0.6rem 0.75rem;
@ -537,4 +858,62 @@ defineExpose({
padding: 0.625rem 0.875rem;
}
}
// @
.mention-dropdown-wrapper {
position: absolute;
z-index: 1000;
}
.mention-popup {
min-width: 240px;
max-height: 280px;
overflow-y: auto;
background: var(--gray-0);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border: 1px solid var(--gray-200);
.mention-group {
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
}
.mention-group-title {
font-size: 12px;
color: var(--gray-500);
padding: 4px 8px;
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--gray-100);
margin-bottom: 2px;
}
.mention-item {
padding: 4px 8px;
cursor: pointer;
font-size: 13px;
color: var(--gray-700);
transition: all 0.15s ease;
margin: 1px 4px;
border-radius: 4px;
&:hover,
&.active {
background-color: var(--main-10);
color: var(--main-600);
}
}
.mention-empty {
text-align: center;
padding: 12px 8px;
color: var(--gray-400);
font-size: 13px;
}
}
</style>

View File

@ -0,0 +1,158 @@
import { ref, computed } from 'vue'
/**
* @typedef {Object} MentionFile
* @property {string} path - 文件路径
* @property {string} [content] - 文件内容
* @property {string} [modified_at] - 修改时间
* @property {number} [size] - 文件大小
*/
/**
* @typedef {Object} MentionKnowledgeBase
* @property {string} db_id - 知识库ID
* @property {string} name - 知识库名称
*/
/**
* @typedef {Object} MentionMcp
* @property {string} name - MCP 名称
* @property {string} [description] - 描述
*/
/**
* @typedef {Object} MentionConfig
* @property {MentionFile[]} [files] - 可引用的文件列表
* @property {MentionKnowledgeBase[]} [knowledgeBases] - 可引用的知识库列表
* @property {MentionMcp[]} [mcps] - 可引用的 MCP 服务器列表
*/
/**
* @typedef {Object} MentionItem
* @property {string} value - 显示和插入的值
* @property {string} label - 显示标签
* @property {'file'|'knowledge'|'mcp'} type - 类型
* @property {string} [description] - 描述信息
*/
/**
* @typedef {Object} UseMentionReturn
* @property {import('vue').Ref<MentionConfig>} mentionConfig - 当前的 mention 配置
* @property {Function} setMention - 设置 mention 配置
* @property {Function} updateFiles - 更新文件列表
* @property {Function} updateKnowledgeBases - 更新知识库列表
* @property {Function} updateMcps - 更新 MCP 列表
* @property {Function} getFilteredItems - 根据查询获取过滤后的候选列表
*/
/**
* Mention @提及 功能管理
* @returns {UseMentionReturn}
*/
export function useMention() {
const mentionConfig = ref({
files: [],
knowledgeBases: [],
mcps: []
})
/**
* 设置完整的 mention 配置
* @param {MentionConfig} config
*/
const setMention = (config) => {
mentionConfig.value = {
files: config.files || [],
knowledgeBases: config.knowledgeBases || [],
mcps: config.mcps || []
}
}
/**
* 更新文件列表
* @param {MentionFile[]} files
*/
const updateFiles = (files) => {
mentionConfig.value.files = files || []
}
/**
* 更新知识库列表
* @param {MentionKnowledgeBase[]} knowledgeBases
*/
const updateKnowledgeBases = (knowledgeBases) => {
mentionConfig.value.knowledgeBases = knowledgeBases || []
}
/**
* 更新 MCP 服务器列表
* @param {MentionMcp[]} mcps
*/
const updateMcps = (mcps) => {
mentionConfig.value.mcps = mcps || []
}
/**
* 获取分类后的所有候选项
* @returns {{ files: MentionItem[], knowledgeBases: MentionItem[], mcps: MentionItem[] }}
*/
const getCategorizedItems = () => {
const { files, knowledgeBases, mcps } = mentionConfig.value
const fileItems = files.map((f) => ({
value: f.path,
label: f.path.split('/').pop() || f.path,
type: 'file',
description: f.path
}))
const kbItems = knowledgeBases.map((kb) => ({
value: kb.name,
label: kb.name,
type: 'knowledge',
description: kb.db_id
}))
const mcpItems = mcps.map((m) => ({
value: m.name,
label: m.name,
type: 'mcp',
description: m.description || ''
}))
return {
files: fileItems,
knowledgeBases: kbItems,
mcps: mcpItems
}
}
/**
* 根据查询字符串过滤候选项
* @param {string} query - 查询字符串不含 @ 符号
* @returns {{ files: MentionItem[], knowledgeBases: MentionItem[], mcps: MentionItem[] }}
*/
const getFilteredItems = (query = '') => {
const lowerQuery = query.toLowerCase()
const categorized = getCategorizedItems()
const filterItems = (items) =>
items.filter((item) => item.label.toLowerCase().includes(lowerQuery))
return {
files: filterItems(categorized.files),
knowledgeBases: filterItems(categorized.knowledgeBases),
mcps: filterItems(categorized.mcps)
}
}
return {
mentionConfig,
setMention,
updateFiles,
updateKnowledgeBases,
updateMcps,
getFilteredItems,
getCategorizedItems
}
}