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

920 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="input-box"
:class="[customClasses, { 'single-line': isSingleLine }]"
@click="focusInput"
>
<div class="top-slot">
<slot name="top"></slot>
</div>
<div class="expand-options" v-if="hasOptionsLeft">
<a-popover
v-model:open="optionsExpanded"
placement="bottomLeft"
trigger="click"
:overlay-inner-style="{ padding: '4px' }"
>
<template #content>
<slot name="options-left">
<div class="no-options">没有配置 options</div>
</slot>
</template>
<a-button type="text" class="expand-btn">
<template #icon>
<PlusOutlined :class="{ rotated: optionsExpanded }" />
</template>
</a-button>
</a-popover>
<slot name="actions-left"></slot>
</div>
<textarea
ref="inputRef"
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 ? '停止回答' : ''">
<a-button
@click="handleSendOrStop"
:disabled="sendButtonDisabled"
type="link"
class="send-button"
>
<template #icon>
<component :is="getIcon" class="send-btn" />
</template>
</a-button>
</a-tooltip>
</div>
<div class="bottom-slot">
<slot name="bottom"></slot>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
onMounted,
nextTick,
watch,
onBeforeUnmount,
useSlots,
onUnmounted
} from 'vue'
import {
SendOutlined,
ArrowUpOutlined,
LoadingOutlined,
PauseOutlined,
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
// 用于防抖的定时器
const debounceTimer = ref(null)
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '输入问题...'
},
isLoading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
sendButtonDisabled: {
type: Boolean,
default: false
},
autoSize: {
type: Object,
default: () => ({ minRows: 2, maxRows: 6 })
},
sendIcon: {
type: String,
default: 'ArrowUpOutlined'
},
customClasses: {
type: Object,
default: () => ({})
},
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(() => {
if (props.forceMultiLine) return false
return isSingleLineMode.value
})
const hasOptionsLeft = computed(() => {
const slot = slots['options-left']
if (!slot) {
return false
}
const renderedNodes = slot()
return Boolean(renderedNodes && renderedNodes.length)
})
const hasActionsLeft = computed(() => {
const slot = slots['actions-left']
if (!slot) {
return false
}
const renderedNodes = slot()
return Boolean(renderedNodes && renderedNodes.length)
})
// 图标映射
const iconComponents = {
SendOutlined: SendOutlined,
ArrowUpOutlined: ArrowUpOutlined,
PauseOutlined: PauseOutlined
}
// 根据传入的图标名动态获取组件
const getIcon = computed(() => {
if (props.isLoading) {
return PauseOutlined
}
return iconComponents[props.sendIcon] || ArrowUpOutlined
})
// 创建本地引用以进行双向绑定
const inputValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 处理键盘事件
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)
})
}
}
// 处理发送按钮点击
const handleSendOrStop = () => {
emit('send')
}
// 用于存储固定的单行宽度基准
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) {
return
}
const textarea = inputRef.value
const content = inputValue.value
// 主要判断依据:内容是否包含换行符
const hasNewlines = content.includes('\n')
// 辅助判断:内容是否超出单行宽度(使用固定的单行宽度基准)
let contentExceedsWidth = false
if (!hasNewlines && content.trim() && singleLineWidth.value > 0) {
// 使用固定的单行宽度作为测量基准,避免因模式切换导致的宽度变化
const measureDiv = document.createElement('div')
measureDiv.style.cssText = `
position: absolute;
visibility: hidden;
white-space: nowrap;
font-family: ${getComputedStyle(textarea).fontFamily};
font-size: ${getComputedStyle(textarea).fontSize};
line-height: ${getComputedStyle(textarea).lineHeight};
padding: 0;
border: none;
width: ${singleLineWidth.value}px;
`
measureDiv.textContent = content
document.body.appendChild(measureDiv)
// 检查内容是否会换行(基于固定的单行宽度)
contentExceedsWidth = measureDiv.scrollWidth > measureDiv.clientWidth
document.body.removeChild(measureDiv)
}
const shouldBeMultiLine = hasNewlines || contentExceedsWidth
isSingleLineMode.value = !shouldBeMultiLine
// 根据模式调整高度
if (shouldBeMultiLine || props.forceMultiLine) {
// 多行模式让textarea自适应内容高度
textarea.style.height = 'auto'
textarea.style.height = `${Math.max(textarea.scrollHeight, singleLineHeight.value)}px`
} else {
// 单行模式清除内联样式让CSS控制高度
textarea.style.height = ''
}
}
// 聚焦输入框
const focusInput = () => {
if (inputRef.value && !props.disabled) {
inputRef.value.focus()
}
}
// 监听输入值变化
watch(inputValue, () => {
nextTick(() => {
checkLineCount()
})
})
// 监听输入框尺寸变化
/* const observeTextareaResize = () => {
if (inputRef.value) {
const textarea = inputRef.value;
if (textarea) {
// 创建 ResizeObserver 来监听文本域尺寸变化
const resizeObserver = new ResizeObserver(() => {
checkLineCount();
});
resizeObserver.observe(textarea);
// 在组件卸载时断开观察器
onBeforeUnmount(() => {
resizeObserver.disconnect();
});
}
}
}; */
// Wait for component to mount before setting up onStartTyping
onMounted(() => {
// console.log('Component mounted');
document.addEventListener('click', closeMentionPopup)
nextTick(() => {
if (inputRef.value) {
// 记录单行模式下的高度和宽度基准
singleLineHeight.value = inputRef.value.clientHeight
singleLineWidth.value = inputRef.value.clientWidth
checkLineCount()
inputRef.value.focus()
}
})
// observeTextareaResize();
})
// 组件卸载时清除定时器和事件监听器
onBeforeUnmount(() => {
if (debounceTimer.value) {
clearTimeout(debounceTimer.value)
}
document.removeEventListener('click', closeMentionPopup)
})
// 公开方法供父组件调用
defineExpose({
focus: () => inputRef.value?.focus(),
closeOptions: () => {
optionsExpanded.value = false
}
})
</script>
<style lang="less" scoped>
.input-box {
display: grid;
width: 100%;
margin: 0 auto;
border: 1px solid var(--gray-150);
border-radius: 0.8rem;
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;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto auto;
grid-template-areas:
'top top'
'input input'
'options send';
.top-slot {
display: flex;
grid-area: top;
}
.expand-options {
grid-area: options;
justify-self: start;
display: flex;
align-items: center;
gap: 8px;
}
.user-input {
grid-area: input;
}
.send-button-container {
grid-area: send;
justify-self: end;
}
.bottom-slot {
grid-column: 1 / -1;
}
// &:focus-within {
// border-color: var(--main-500);
// background: var(--gray-0);
// box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
// }
&.single-line {
padding: 0.75rem 0.75rem;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto 1fr auto;
grid-template-areas:
'top top top'
'options input send'
'bottom bottom bottom';
align-items: center;
gap: 0px;
.user-input {
min-height: 24px;
height: 24px; /* Fix height for single line */
align-self: center;
white-space: nowrap;
overflow: hidden;
margin-bottom: 0rem;
}
.expand-options,
.send-button-container {
align-self: center;
}
.expand-options {
display: flex;
align-items: center;
gap: 8px;
}
.top-slot {
grid-area: top;
}
.bottom-slot {
grid-area: bottom;
}
}
}
.expand-options {
grid-area: options;
display: flex;
align-items: center;
}
.user-input {
grid-area: input;
width: 100%;
padding: 0;
background-color: transparent;
border: none;
margin: 0;
margin-bottom: 0.5rem;
color: var(--gray-1000);
font-size: 15px;
outline: none;
resize: none;
line-height: 1.5;
font-family: inherit;
min-height: 44px; /* Default min-height for multi-line */
max-height: 200px;
&:focus {
outline: none;
box-shadow: none;
}
&::placeholder {
color: var(--gray-400);
}
}
.send-button-container {
grid-area: send;
display: flex;
align-items: center;
justify-content: center;
}
.expand-btn {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-600);
transition: all 0.2s ease;
border: 1px solid transparent;
background-color: transparent;
&:hover {
color: var(--main-color);
}
&:active {
color: var(--main-color);
transform: scale(0.95);
}
.anticon {
font-size: 14px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.rotated {
transform: rotate(45deg);
}
}
}
// Popover 选项样式
.popover-options {
min-width: 160px;
max-width: 200px;
padding: 4px;
.no-options {
color: var(--gray-500);
font-size: 12px;
text-align: center;
padding: 12px 8px;
}
:deep(.opt-item) {
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
color: var(--gray-700);
transition: all 0.2s ease;
margin: 2px;
display: inline-block;
&:hover {
background-color: var(--main-10);
color: var(--main-600);
}
&.active {
color: var(--main-600);
background-color: var(--main-10);
}
}
}
.send-button.ant-btn-icon-only {
height: 32px;
width: 32px;
cursor: pointer;
background-color: var(--main-500);
border-radius: 50%;
border: none;
transition: all 0.2s ease;
box-shadow: 0 2px 6px var(--shadow-2);
color: var(--gray-0);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
&:hover {
background-color: var(--main-color);
box-shadow: 0 4px 8px var(--shadow-3);
color: var(--gray-0);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 4px var(--shadow-2);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
@media (max-width: 520px) {
.input-box {
border-radius: 15px;
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>