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 './tasker' // 任务管理API
|
||||||
export * from './mindmap_api' // 思维导图API
|
export * from './mindmap_api' // 思维导图API
|
||||||
export * from './department_api' // 部门管理API
|
export * from './department_api' // 部门管理API
|
||||||
|
export * from './mcp_api' // MCP API
|
||||||
|
|
||||||
// 导出基础工具函数
|
// 导出基础工具函数
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -136,6 +136,7 @@
|
|||||||
:ensure-thread="ensureActiveThread"
|
:ensure-thread="ensureActiveThread"
|
||||||
:has-state-content="hasAgentStateContent"
|
:has-state-content="hasAgentStateContent"
|
||||||
:is-panel-open="isAgentPanelOpen"
|
:is-panel-open="isAgentPanelOpen"
|
||||||
|
:mention="mentionConfig"
|
||||||
@send="handleSendOrStop"
|
@send="handleSendOrStop"
|
||||||
@attachment-changed="handleAgentStateRefresh"
|
@attachment-changed="handleAgentStateRefresh"
|
||||||
@toggle-panel="toggleAgentPanel"
|
@toggle-panel="toggleAgentPanel"
|
||||||
@ -209,7 +210,7 @@ import { useAgentStore } from '@/stores/agent'
|
|||||||
import { useChatUIStore } from '@/stores/chatUI'
|
import { useChatUIStore } from '@/stores/chatUI'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { MessageProcessor } from '@/utils/messageProcessor'
|
import { MessageProcessor } from '@/utils/messageProcessor'
|
||||||
import { agentApi, threadApi } from '@/apis'
|
import { agentApi, threadApi, databaseApi, mcpApi } from '@/apis'
|
||||||
import HumanApprovalModal from '@/components/HumanApprovalModal.vue'
|
import HumanApprovalModal from '@/components/HumanApprovalModal.vue'
|
||||||
import { useApproval } from '@/composables/useApproval'
|
import { useApproval } from '@/composables/useApproval'
|
||||||
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
|
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
|
||||||
@ -225,7 +226,14 @@ const emit = defineEmits(['open-config', 'open-agent-modal'])
|
|||||||
// ==================== STORE MANAGEMENT ====================
|
// ==================== STORE MANAGEMENT ====================
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
const chatUIStore = useChatUIStore()
|
const chatUIStore = useChatUIStore()
|
||||||
const { agents, selectedAgentId, defaultAgentId, selectedAgentConfigId } = storeToRefs(agentStore)
|
const {
|
||||||
|
agents,
|
||||||
|
selectedAgentId,
|
||||||
|
defaultAgentId,
|
||||||
|
selectedAgentConfigId,
|
||||||
|
agentConfig,
|
||||||
|
configurableItems
|
||||||
|
} = storeToRefs(agentStore)
|
||||||
|
|
||||||
// ==================== LOCAL CHAT & UI STATE ====================
|
// ==================== LOCAL CHAT & UI STATE ====================
|
||||||
const userInput = ref('')
|
const userInput = ref('')
|
||||||
@ -268,6 +276,10 @@ const localUIState = reactive({
|
|||||||
isInitialRender: true
|
isInitialRender: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mention resources
|
||||||
|
const availableKnowledgeBases = ref([])
|
||||||
|
const availableMcps = ref([])
|
||||||
|
|
||||||
// Agent Panel State
|
// Agent Panel State
|
||||||
const isAgentPanelOpen = ref(false)
|
const isAgentPanelOpen = ref(false)
|
||||||
const isResizing = ref(false)
|
const isResizing = ref(false)
|
||||||
@ -343,6 +355,66 @@ const hasAgentStateContent = computed(() => {
|
|||||||
return todoCount > 0 || fileCount > 0 || attachmentCount > 0
|
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] || [])
|
const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || [])
|
||||||
|
|
||||||
// 计算是否显示Refs组件的条件
|
// 计算是否显示Refs组件的条件
|
||||||
@ -595,6 +667,19 @@ const fetchAgentState = async (agentId, threadId) => {
|
|||||||
} catch (error) {}
|
} 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 = '新的对话') => {
|
const ensureActiveThread = async (title = '新的对话') => {
|
||||||
if (currentChatId.value) return currentChatId.value
|
if (currentChatId.value) return currentChatId.value
|
||||||
try {
|
try {
|
||||||
@ -1096,6 +1181,7 @@ const initAll = async () => {
|
|||||||
if (!agentStore.isInitialized) {
|
if (!agentStore.isInitialized) {
|
||||||
await agentStore.initialize()
|
await agentStore.initialize()
|
||||||
}
|
}
|
||||||
|
await fetchMentionResources()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleChatError(error, 'load')
|
handleChatError(error, 'load')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
:send-button-disabled="sendButtonDisabled"
|
:send-button-disabled="sendButtonDisabled"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:force-multi-line="hasStateContent"
|
:force-multi-line="hasStateContent"
|
||||||
|
:mention="mention"
|
||||||
@send="handleSend"
|
@send="handleSend"
|
||||||
@keydown="handleKeyDown"
|
@keydown="handleKeyDown"
|
||||||
>
|
>
|
||||||
@ -68,7 +69,8 @@ const props = defineProps({
|
|||||||
threadId: { type: String, default: null },
|
threadId: { type: String, default: null },
|
||||||
ensureThread: { type: Function, required: true },
|
ensureThread: { type: Function, required: true },
|
||||||
hasStateContent: { type: Boolean, default: false },
|
hasStateContent: { type: Boolean, default: false },
|
||||||
isPanelOpen: { type: Boolean, default: false }
|
isPanelOpen: { type: Boolean, default: false },
|
||||||
|
mention: { type: Object, default: () => null }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
|
|||||||
@ -34,12 +34,65 @@
|
|||||||
class="user-input"
|
class="user-input"
|
||||||
:value="inputValue"
|
:value="inputValue"
|
||||||
@keydown="handleKeyPress"
|
@keydown="handleKeyPress"
|
||||||
|
@keyup="handleKeyUp"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="focusInput"
|
@focus="focusInput"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="disabled"
|
: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">
|
<div class="send-button-container">
|
||||||
<slot name="actions-right"></slot>
|
<slot name="actions-right"></slot>
|
||||||
<a-tooltip :title="isLoading ? '停止回答' : ''">
|
<a-tooltip :title="isLoading ? '停止回答' : ''">
|
||||||
@ -63,7 +116,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, nextTick, watch, onBeforeUnmount, useSlots } from 'vue'
|
import {
|
||||||
|
ref,
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
onBeforeUnmount,
|
||||||
|
useSlots,
|
||||||
|
onUnmounted
|
||||||
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
@ -72,6 +134,15 @@ import {
|
|||||||
PlusOutlined
|
PlusOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} 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 inputRef = ref(null)
|
||||||
const optionsExpanded = ref(false)
|
const optionsExpanded = ref(false)
|
||||||
const singleLineHeight = ref(0) // Add this
|
const singleLineHeight = ref(0) // Add this
|
||||||
@ -113,12 +184,213 @@ const props = defineProps({
|
|||||||
forceMultiLine: {
|
forceMultiLine: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
mention: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'send', 'keydown'])
|
const emit = defineEmits(['update:modelValue', 'send', 'keydown'])
|
||||||
const slots = useSlots()
|
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
|
// Update isSingleLine logic to respect forceMultiLine
|
||||||
const isSingleLineMode = ref(true)
|
const isSingleLineMode = ref(true)
|
||||||
const isSingleLine = computed(() => {
|
const isSingleLine = computed(() => {
|
||||||
@ -167,13 +439,53 @@ const inputValue = computed({
|
|||||||
|
|
||||||
// 处理键盘事件
|
// 处理键盘事件
|
||||||
const handleKeyPress = (e) => {
|
const handleKeyPress = (e) => {
|
||||||
|
// @ 提及键盘导航
|
||||||
|
if (mentionPopupVisible.value) {
|
||||||
|
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key)) {
|
||||||
|
handleMentionNavigation(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emit('keydown', e)
|
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 handleInput = (e) => {
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
emit('update:modelValue', 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 singleLineWidth = ref(0)
|
||||||
|
|
||||||
|
// @ 提及功能状态
|
||||||
|
const mentionPopupVisible = ref(false)
|
||||||
|
const mentionQuery = ref('')
|
||||||
|
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [] })
|
||||||
|
const mentionSelectedIndex = ref(0)
|
||||||
|
|
||||||
// 检查行数
|
// 检查行数
|
||||||
const checkLineCount = () => {
|
const checkLineCount = () => {
|
||||||
if (!inputRef.value || singleLineHeight.value === 0) {
|
if (!inputRef.value || singleLineHeight.value === 0) {
|
||||||
@ -269,6 +587,7 @@ watch(inputValue, () => {
|
|||||||
// Wait for component to mount before setting up onStartTyping
|
// Wait for component to mount before setting up onStartTyping
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// console.log('Component mounted');
|
// console.log('Component mounted');
|
||||||
|
document.addEventListener('click', closeMentionPopup)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (inputRef.value) {
|
if (inputRef.value) {
|
||||||
// 记录单行模式下的高度和宽度基准
|
// 记录单行模式下的高度和宽度基准
|
||||||
@ -281,11 +600,12 @@ onMounted(() => {
|
|||||||
// observeTextareaResize();
|
// observeTextareaResize();
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清除定时器
|
// 组件卸载时清除定时器和事件监听器
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (debounceTimer.value) {
|
if (debounceTimer.value) {
|
||||||
clearTimeout(debounceTimer.value)
|
clearTimeout(debounceTimer.value)
|
||||||
}
|
}
|
||||||
|
document.removeEventListener('click', closeMentionPopup)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 公开方法供父组件调用
|
// 公开方法供父组件调用
|
||||||
@ -307,6 +627,7 @@ defineExpose({
|
|||||||
box-shadow: 0 2px 8px var(--shadow-1);
|
box-shadow: 0 2px 8px var(--shadow-1);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
gap: 0px;
|
gap: 0px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
/* Default: Multi-line layout with top/bottom slots */
|
/* Default: Multi-line layout with top/bottom slots */
|
||||||
padding: 0.8rem 0.75rem 0.6rem 0.75rem;
|
padding: 0.8rem 0.75rem 0.6rem 0.75rem;
|
||||||
@ -537,4 +858,62 @@ defineExpose({
|
|||||||
padding: 0.625rem 0.875rem;
|
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>
|
</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