feat(web): 在聊天输入中实现并优化 @ 提及功能
- 在 MessageInputComponent 中启用 @ 提及触发器,并支持键盘导航。 - 通过 AgentChatComponent 将提及配置传递到 AgentInputArea,最终传递至输入组件。 - 实现根据所选代理配置获取并筛选知识库和 MCP 的逻辑。 - 在提及候选列表中包含标准化的代理文件和上传的附件。 - 通过向上展开的弹窗、键盘选择时自动滚动以及自动插入空格来提升用户体验。 - 优化提及弹窗样式(宽度、字体大小、间距),移除不必要的图标以保持界面简洁。
This commit is contained in:
parent
5348106513
commit
34b06b297e
@ -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 {
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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>
|
||||
|
||||
158
web/src/composables/useMention.js
Normal file
158
web/src/composables/useMention.js
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user