diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md
index 41a8af0e..34fe5c6d 100644
--- a/docs/develop-guides/roadmap.md
+++ b/docs/develop-guides/roadmap.md
@@ -70,7 +70,7 @@
- 标准化 Agent run/SSE 执行链路:run 创建时持久化输入消息并提交后入队,worker 统一写入 Redis Stream envelope,SSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。
- 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`。
- 收敛文件服务边界:文件预览判断抽为独立服务,Viewer 文件系统的 workspace 分支复用用户 workspace 服务,线程运行时上下文解析从泛化 `filesystem_service` 拆出为 agent runtime helper。
-- 优化聊天输入 @ 文件提及:未创建 Thread 时可搜索用户 workspace,创建 Thread 后按当前对话文件优先、workspace 兜底的来源顺序搜索,并拆分 workspace/thread 缓存避免假 thread 与跨用户缓存污染。
+- 优化聊天输入 @ 文件提及:未创建 Thread 时可搜索用户 workspace,创建 Thread 后按当前对话文件优先、workspace 兜底的来源顺序搜索,并拆分 workspace/thread 缓存避免假 thread 与跨用户缓存污染;输入框与用户消息支持将 raw mention 渲染为带类型图标的引用单元,文件仅显示文件名且保留原始沙盒路径文本。
---
diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue
index 50c5bb12..22d37936 100644
--- a/web/src/components/AgentChatComponent.vue
+++ b/web/src/components/AgentChatComponent.vue
@@ -55,6 +55,7 @@
:is-processing="isDisplayMessageProcessing(row.conv, displayItem)"
:show-refs="showMsgRefs(displayItem.message)"
:hide-tool-calls="true"
+ :mention="mentionConfig"
@retry="retryMessage(displayItem.message)"
>
diff --git a/web/src/components/AgentMessageComponent.vue b/web/src/components/AgentMessageComponent.vue
index e9e65614..62b54870 100644
--- a/web/src/components/AgentMessageComponent.vue
+++ b/web/src/components/AgentMessageComponent.vue
@@ -23,7 +23,9 @@
-
{{ message.content }}
+
+
+
{{ message.content }}
@@ -139,11 +141,13 @@ import RefsComponent from '@/components/RefsComponent.vue'
import { Copy, Check } from 'lucide-vue-next'
import ToolCallsGroupComponent from '@/components/ToolCallsGroupComponent.vue'
import MarkdownPreview from '@/components/common/MarkdownPreview.vue'
+import MentionTextRenderer from '@/components/common/MentionTextRenderer.vue'
import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { storeToRefs } from 'pinia'
import { MessageProcessor } from '@/utils/messageProcessor'
import { normalizeAttachmentPreviews } from '@/utils/file_utils'
+import { buildMentionDisplayLabels } from '@/utils/mention_utils'
const props = defineProps({
// 消息角色:'user'|'assistant'|'sent'|'received'
@@ -175,6 +179,10 @@ const props = defineProps({
type: Boolean,
default: false
},
+ mention: {
+ type: Object,
+ default: () => null
+ },
// 是否显示调试信息 (已废弃,使用 infoStore.debugMode)
debugMode: {
type: Boolean,
@@ -264,6 +272,8 @@ const fileAttachments = computed(() =>
messageAttachments.value.filter((attachment) => !attachment.isImage || !attachment.previewUrl)
)
+const mentionDisplayLabels = computed(() => buildMentionDisplayLabels(props.mention || {}))
+
const messageSources = computed(() => {
if (props.message.type === 'ai') {
return MessageProcessor.extractSourcesFromMessage(props.message, availableKnowledgeBases.value)
diff --git a/web/src/components/AttachmentTmpUploadModal.vue b/web/src/components/AttachmentTmpUploadModal.vue
index a00cc9bf..0a5a5b97 100644
--- a/web/src/components/AttachmentTmpUploadModal.vue
+++ b/web/src/components/AttachmentTmpUploadModal.vue
@@ -32,7 +32,7 @@
@@ -136,7 +136,7 @@
:loading="item.status === 'parsing'"
:disabled="confirming"
>
- 解析
+ 可解析
@@ -546,12 +546,6 @@ const formatFileSize = (size) => {
}
.remove-btn {
- display: inline-flex;
- flex: none;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
color: var(--gray-500);
}
@@ -565,7 +559,6 @@ const formatFileSize = (size) => {
align-items: center;
justify-content: space-between;
gap: 8px;
- margin-top: 6px;
color: var(--gray-500);
font-size: 12px;
}
@@ -585,6 +578,7 @@ const formatFileSize = (size) => {
.parse-trigger-btn {
flex: none;
margin-left: auto;
+ font-size: 12px;
}
.parse-panel {
diff --git a/web/src/components/MessageInputComponent.vue b/web/src/components/MessageInputComponent.vue
index 9e766db9..d20e49d9 100644
--- a/web/src/components/MessageInputComponent.vue
+++ b/web/src/components/MessageInputComponent.vue
@@ -25,22 +25,27 @@
-
+ @click="handleEditorClick"
+ @paste="handlePaste"
+ @compositionstart="handleCompositionStart"
+ @compositionend="handleCompositionEnd"
+ >
-
@@ -130,15 +169,32 @@
- {{ part.text }}
+ {{ part.text }}
+
+
+ {{ part.text }}
+
@@ -148,15 +204,32 @@
- {{ part.text }}
+ {{ part.text }}
+
+
+ {{ part.text }}
+
@@ -193,6 +266,16 @@ import { SendOutlined, ArrowUpOutlined, PauseOutlined, FolderFilled } from '@ant
import { Paperclip } from 'lucide-vue-next'
import { searchMentionFiles } from '@/apis/mention_api'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
+import {
+ buildMentionDisplayLabels,
+ expandMentionDeletionRange,
+ findActiveMentionQuery,
+ formatMentionToken,
+ getMentionDisplayLabel,
+ mentionTypePrefixMap,
+ parseMentionText,
+ replaceRawRange
+} from '@/utils/mention_utils'
// 点击外部关闭下拉框
const mentionDropdownRef = ref(null)
@@ -258,17 +341,25 @@ const mentionEnabled = computed(() => {
return !!props.mention
})
-const mentionTypePrefixMap = {
- file: 'file',
- knowledge: 'knowledge',
- mcp: 'mcp',
- skill: 'skill',
- subagent: 'subagent'
+const mentionDisplayLabels = computed(() => buildMentionDisplayLabels(props.mention || {}))
+
+let lastRawSelectionRange = null
+let lastSyncedEditorValue = props.modelValue || ''
+
+const getStoredRawSelectionRange = () => {
+ const length = getEditorRawValue().length
+ if (!lastRawSelectionRange) {
+ return { start: length, end: length, collapsed: true }
+ }
+
+ const start = Math.max(0, Math.min(lastRawSelectionRange.start, length))
+ const end = Math.max(start, Math.min(lastRawSelectionRange.end, length))
+ return { start, end, collapsed: start === end }
}
-const formatMentionToken = (type, value) => {
- const prefix = mentionTypePrefixMap[type] || type
- return `@${prefix}:${value}`
+const rememberRawSelectionRange = (range) => {
+ lastRawSelectionRange = { ...range }
+ return range
}
const formatMentionPath = (path) => {
@@ -288,6 +379,12 @@ const formatMentionPath = (path) => {
return pathForParent.substring(0, lastSlashIndex + 1)
}
+const getMentionDescription = (description) => {
+ const value = String(description || '').trim()
+ if (!value || value === '暂无描述') return ''
+ return value
+}
+
// 高性能且安全的关键字切片高亮解析函数 (100% 防御 XSS,避开危险的 v-html)
const splitTextByQuery = (text, query) => {
if (!text) return []
@@ -303,20 +400,243 @@ const splitTextByQuery = (text, query) => {
}))
}
+const isTextNode = (node) => node?.nodeType === Node.TEXT_NODE
+const isElementNode = (node) => node?.nodeType === Node.ELEMENT_NODE
+const isMentionNode = (node) => isElementNode(node) && node.dataset?.mentionRaw !== undefined
+const isLineBreakNode = (node) => isElementNode(node) && node.tagName === 'BR'
+const childIndex = (node) => Array.prototype.indexOf.call(node.parentNode?.childNodes || [], node)
+
+const getRawNodeLength = (node) => {
+ if (!node) return 0
+ if (isTextNode(node)) return node.textContent?.length || 0
+ if (isMentionNode(node)) return node.dataset.mentionRaw?.length || 0
+ if (isLineBreakNode(node)) return 1
+ return Array.from(node.childNodes || []).reduce((total, child) => total + getRawNodeLength(child), 0)
+}
+
+const serializeEditorNode = (node) => {
+ if (!node) return ''
+ if (isTextNode(node)) return node.textContent || ''
+ if (isMentionNode(node)) return node.dataset.mentionRaw || ''
+ if (isLineBreakNode(node)) return '\n'
+ return Array.from(node.childNodes || [])
+ .map((child) => serializeEditorNode(child))
+ .join('')
+}
+
+const serializeEditorContent = () => serializeEditorNode(inputRef.value)
+const getEditorRawValue = () => (inputRef.value ? serializeEditorContent() : inputValue.value)
+
+const getEditorMentionIconSvg = (type) => {
+ if (type === 'knowledge') {
+ return ''
+ }
+ if (type === 'skill') {
+ return ''
+ }
+ if (type === 'subagent') {
+ return ''
+ }
+ if (type === 'mcp') {
+ return ''
+ }
+ return ''
+}
+
+const createEditorMentionElement = (segment) => {
+ const token = document.createElement('span')
+ token.className = `mention-ref-token mention-ref-${segment.type} mention-ref-editable`
+ token.contentEditable = 'false'
+ token.dataset.mentionRaw = segment.raw
+ token.dataset.mentionType = segment.type
+ token.dataset.mentionValue = segment.value
+ token.title = segment.raw
+
+ const icon = document.createElement('span')
+ icon.className = 'mention-ref-icon'
+ icon.innerHTML = getEditorMentionIconSvg(segment.type)
+ if (segment.type === 'file') {
+ icon.style.color = segment.value.endsWith('/') ? '#ffa940' : getFileIconColor(segment.value)
+ }
+ token.appendChild(icon)
+
+ const label = document.createElement('span')
+ label.className = 'mention-ref-label'
+ label.textContent = getMentionDisplayLabel(segment.type, segment.value, mentionDisplayLabels.value)
+ token.appendChild(label)
+
+ return token
+}
+
+const renderEditorContent = (raw = '') => {
+ const editor = inputRef.value
+ if (!editor) return
+
+ editor.replaceChildren()
+ parseMentionText(raw).forEach((segment) => {
+ editor.appendChild(
+ segment.kind === 'text' ? document.createTextNode(segment.text) : createEditorMentionElement(segment)
+ )
+ })
+ lastSyncedEditorValue = String(raw || '')
+}
+
+const isNodeInEditor = (node) => {
+ const editor = inputRef.value
+ if (!editor || !node) return false
+ const element = isElementNode(node) ? node : node.parentNode
+ return element === editor || editor.contains(element)
+}
+
+const getRawOffsetFromDomPoint = (container, offset) => {
+ const editor = inputRef.value
+ if (!editor || !isNodeInEditor(container)) return getEditorRawValue().length
+
+ let rawOffset = 0
+ let found = false
+
+ const visit = (node) => {
+ if (!node || found) return
+
+ if (node === container) {
+ if (isTextNode(node)) {
+ rawOffset += Math.min(offset, node.textContent?.length || 0)
+ } else if (isMentionNode(node)) {
+ rawOffset += offset > 0 ? getRawNodeLength(node) : 0
+ } else {
+ const children = Array.from(node.childNodes || [])
+ for (let index = 0; index < Math.min(offset, children.length); index++) {
+ rawOffset += getRawNodeLength(children[index])
+ }
+ }
+ found = true
+ return
+ }
+
+ if (isTextNode(node) || isMentionNode(node) || isLineBreakNode(node)) {
+ rawOffset += getRawNodeLength(node)
+ return
+ }
+
+ for (const child of Array.from(node.childNodes || [])) {
+ visit(child)
+ if (found) return
+ }
+ }
+
+ visit(editor)
+ return rawOffset
+}
+
+const getRawSelectionRange = () => {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0 || !isNodeInEditor(selection.anchorNode)) {
+ return getStoredRawSelectionRange()
+ }
+
+ const anchor = getRawOffsetFromDomPoint(selection.anchorNode, selection.anchorOffset)
+ const focus = getRawOffsetFromDomPoint(selection.focusNode, selection.focusOffset)
+ return rememberRawSelectionRange({
+ start: Math.min(anchor, focus),
+ end: Math.max(anchor, focus),
+ collapsed: anchor === focus
+ })
+}
+
+const getDomPointForRawOffset = (rawOffset) => {
+ const editor = inputRef.value
+ const offset = Math.max(0, Math.min(rawOffset, getEditorRawValue().length))
+ let remaining = offset
+
+ const pointBeforeNode = (node) => ({ node: node.parentNode || editor, offset: childIndex(node) })
+ const pointAfterNode = (node) => ({ node: node.parentNode || editor, offset: childIndex(node) + 1 })
+
+ const visit = (node) => {
+ if (!node) return null
+
+ if (isTextNode(node)) {
+ const length = node.textContent?.length || 0
+ if (remaining <= length) {
+ return { node, offset: remaining }
+ }
+ remaining -= length
+ return null
+ }
+
+ if (isMentionNode(node) || isLineBreakNode(node)) {
+ const length = getRawNodeLength(node)
+ if (remaining === 0) return pointBeforeNode(node)
+ if (remaining <= length) return pointAfterNode(node)
+ remaining -= length
+ return null
+ }
+
+ for (const child of Array.from(node.childNodes || [])) {
+ const point = visit(child)
+ if (point) return point
+ }
+
+ return node === editor ? { node: editor, offset: editor.childNodes.length } : null
+ }
+
+ return visit(editor) || { node: editor, offset: editor?.childNodes.length || 0 }
+}
+
+const restoreEditorSelection = (start, end = start) => {
+ const editor = inputRef.value
+ const selection = window.getSelection()
+ if (!editor || !selection) return
+
+ const startPoint = getDomPointForRawOffset(start)
+ const endPoint = getDomPointForRawOffset(end)
+ const range = document.createRange()
+ range.setStart(startPoint.node, startPoint.offset)
+ range.setEnd(endPoint.node, endPoint.offset)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ rememberRawSelectionRange({ start, end, collapsed: start === end })
+ editor.focus()
+}
+
+const updateRawValue = (value, caretStart, caretEnd = caretStart) => {
+ renderEditorContent(value)
+ emit('update:modelValue', value)
+ nextTick(() => {
+ restoreEditorSelection(caretStart, caretEnd)
+ adjustTextareaHeight()
+ if (mentionEnabled.value) {
+ checkMentionTrigger()
+ }
+ })
+}
+
+const replaceCurrentRawSelection = (replacement) => {
+ const currentValue = getEditorRawValue()
+ const range = getRawSelectionRange()
+ const nextValue = replaceRawRange(currentValue, range.start, range.end, replacement)
+ const nextOffset = range.start + replacement.length
+ updateRawValue(nextValue, nextOffset)
+}
+
// 检测是否在 @ 触发位置
-const checkMentionTrigger = (textarea) => {
- if (!textarea || !mentionEnabled.value) return false
+const checkMentionTrigger = () => {
+ if (!inputRef.value || !mentionEnabled.value) return false
- const cursorPos = textarea.selectionStart
- const textBeforeCursor = inputValue.value.slice(0, cursorPos)
+ const selectionRange = getRawSelectionRange()
+ if (!selectionRange.collapsed) {
+ mentionPopupVisible.value = false
+ return false
+ }
- // 检查是否以 @ 结尾(刚输入 @)或 @ 后有内容
- const atMatch = textBeforeCursor.match(/@(\S*)$/)
- if (atMatch) {
- mentionQuery.value = atMatch[1]
+ const activeMention = findActiveMentionQuery(getEditorRawValue(), selectionRange.end)
+ if (activeMention) {
+ const sameQuery = mentionPopupVisible.value && mentionQuery.value === activeMention.query
+ mentionQuery.value = activeMention.query
mentionPopupVisible.value = true
- mentionSelectedIndex.value = 0
- updateMentionItems(mentionQuery.value)
+ if (!sameQuery) {
+ mentionSelectedIndex.value = 0
+ updateMentionItems(mentionQuery.value)
+ }
return true
}
@@ -340,6 +660,7 @@ const updateMentionItems = (query = '') => {
item.label,
item.value,
item.description,
+ item.resourceId,
item.tokenLabel,
item.type,
mentionTypePrefixMap[item.type]
@@ -375,43 +696,46 @@ const updateMentionItems = (query = '') => {
type: 'knowledge',
insertValue: kbName,
tokenLabel: formatMentionToken('knowledge', kbName),
- description: kb.kb_id
+ description: kb.description || '',
+ resourceId: kb.kb_id
}
})
const mcpItems = mcps.map((m) => {
- const mcpName = m.name || ''
+ const mcpValue = m.slug || m.value || m.id || m.name || ''
+ const mcpLabel = m.name || m.label || mcpValue
return {
- value: mcpName,
- label: mcpName,
+ value: mcpValue,
+ label: mcpLabel,
type: 'mcp',
- insertValue: mcpName,
- tokenLabel: formatMentionToken('mcp', mcpName),
+ insertValue: mcpValue,
+ tokenLabel: formatMentionToken('mcp', mcpLabel),
description: m.description || ''
}
})
const skillItems = skills.map((skill) => {
- const skillValue = skill.slug || skill.name || skill.id || ''
+ const skillValue = skill.slug || skill.value || skill.id || skill.name || ''
+ const skillLabel = skill.name || skill.label || skillValue
return {
value: skillValue,
- label: skillValue,
+ label: skillLabel,
type: 'skill',
insertValue: skillValue,
- tokenLabel: formatMentionToken('skill', skillValue),
+ tokenLabel: formatMentionToken('skill', skillLabel),
description: skill.description || ''
}
})
const subagentItems = subagents.map((subagent) => {
- const subagentValue = subagent.id || subagent.value || subagent.name || ''
+ const subagentValue = subagent.id || subagent.value || subagent.slug || subagent.name || ''
const subagentLabel = subagent.name || subagent.label || subagentValue
return {
value: subagentValue,
label: subagentLabel,
type: 'subagent',
insertValue: subagentValue,
- tokenLabel: formatMentionToken('subagent', subagentValue),
+ tokenLabel: formatMentionToken('subagent', subagentLabel),
description: subagent.description || ''
}
})
@@ -531,28 +855,19 @@ const hasAnyItems = computed(() => {
const insertMention = (item) => {
if (!inputRef.value) return
- const textarea = inputRef.value
- const cursorPos = textarea.selectionStart
- const textBeforeCursor = inputValue.value.slice(0, cursorPos)
+ const currentValue = getEditorRawValue()
+ const selectionRange = getRawSelectionRange()
+ const activeMention = findActiveMentionQuery(currentValue, selectionRange.end)
+ if (!activeMention) return
+
const mentionValue = item.insertValue || item.value
- const mentionText = formatMentionToken(item.type, mentionValue)
-
- // 移除 @ 及后面的查询内容,插入完整的提及项
- const newTextBefore = textBeforeCursor.replace(/@(\S*)$/, `${mentionText} `)
- 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()
- })
+ const mentionText = `${formatMentionToken(item.type, mentionValue)} `
+ const newValue = replaceRawRange(currentValue, activeMention.start, activeMention.end, mentionText)
+ const newCursorPos = activeMention.start + mentionText.length
mentionPopupVisible.value = false
mentionQuery.value = ''
+ updateRawValue(newValue, newCursorPos)
}
// 滚动到选中项
@@ -643,6 +958,27 @@ const inputValue = computed({
set: (val) => emit('update:modelValue', val)
})
+const handleMentionDeletion = (e) => {
+ if (e.key !== 'Backspace' && e.key !== 'Delete') return false
+
+ const currentValue = getEditorRawValue()
+ const selectionRange = getRawSelectionRange()
+ const expandedRange = expandMentionDeletionRange(
+ currentValue,
+ selectionRange.start,
+ selectionRange.end,
+ e.key === 'Delete' ? 'forward' : 'backward'
+ )
+
+ if (!expandedRange) return false
+
+ e.preventDefault()
+ const nextValue = replaceRawRange(currentValue, expandedRange.start, expandedRange.end, '')
+ mentionPopupVisible.value = false
+ updateRawValue(nextValue, expandedRange.start)
+ return true
+}
+
// 处理键盘事件
const handleKeyPress = (e) => {
// @ 提及键盘导航
@@ -653,29 +989,69 @@ const handleKeyPress = (e) => {
}
}
+ if (handleMentionDeletion(e)) {
+ return
+ }
+
+ if (e.key === 'Enter' && e.shiftKey) {
+ e.preventDefault()
+ replaceCurrentRawSelection('\n')
+ return
+ }
+
emit('keydown', e)
}
+const shouldCheckMentionOnKeyUp = (e) => {
+ if (!e) return false
+ if (e.key.length === 1) return true
+ return e.key === 'Backspace' || e.key === 'Delete'
+}
+
// 检测 @ 触发
const handleKeyUp = (e) => {
- if (e.key === '@' && mentionEnabled.value) {
+ if (!mentionEnabled.value || isComposing.value || !shouldCheckMentionOnKeyUp(e)) return
+ nextTick(() => {
+ checkMentionTrigger()
+ })
+}
+
+// 处理输入事件
+const handleInput = () => {
+ if (isComposing.value) return
+
+ if (inputRef.value && !inputRef.value.querySelector('.mention-ref-token')) {
+ const text = inputRef.value.textContent || ''
+ if (!text.trim()) {
+ inputRef.value.replaceChildren()
+ }
+ }
+
+ const value = serializeEditorContent()
+ lastSyncedEditorValue = value
+ emit('update:modelValue', value)
+ adjustTextareaHeight()
+
+ if (mentionEnabled.value) {
nextTick(() => {
- checkMentionTrigger(e.target)
+ checkMentionTrigger()
})
}
}
-// 处理输入事件
-const handleInput = (e) => {
- const value = e.target.value
- emit('update:modelValue', value)
+const handlePaste = (e) => {
+ e.preventDefault()
+ const text = e.clipboardData?.getData('text/plain') || ''
+ replaceCurrentRawSelection(text)
+}
- // 只要开启了提及功能,在任何输入事件时都使用正则表达式动态检测是否唤醒或更新弹出框
- if (mentionEnabled.value) {
- nextTick(() => {
- checkMentionTrigger(e.target)
- })
- }
+const handleCompositionStart = () => {
+ isComposing.value = true
+}
+
+const handleCompositionEnd = () => {
+ isComposing.value = false
+ handleInput()
}
// 处理发送按钮点击
@@ -689,6 +1065,7 @@ const mentionQuery = ref('')
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [], skills: [], subagents: [] })
const mentionSelectedIndex = ref(0)
const searchRequestId = ref(0)
+const isComposing = ref(false)
let activeAbortController = null
let mentionSearchTimer = null
@@ -709,23 +1086,27 @@ const focusInput = () => {
// 聚焦回来时,如果开启了提及,自动检测当前光标位置是否处于 @提及 范围,是则重新升起弹框
if (mentionEnabled.value) {
nextTick(() => {
- checkMentionTrigger(inputRef.value)
+ checkMentionTrigger()
})
}
}
}
// 处理输入框点击事件,自适应检测光标是否落入 @提及 范围内以唤醒或更新弹窗
-const handleTextareaClick = (e) => {
+const handleEditorClick = () => {
if (mentionEnabled.value) {
nextTick(() => {
- checkMentionTrigger(e.target)
+ checkMentionTrigger()
})
}
}
// 监听输入值变化
-watch(inputValue, () => {
+watch(inputValue, (value) => {
+ if (value !== lastSyncedEditorValue) {
+ renderEditorContent(value || '')
+ }
+
if (debounceTimer.value) {
clearTimeout(debounceTimer.value)
}
@@ -740,6 +1121,7 @@ onMounted(() => {
document.addEventListener('click', closeMentionPopup)
nextTick(() => {
if (inputRef.value) {
+ renderEditorContent(inputValue.value || '')
adjustTextareaHeight()
inputRef.value.focus()
}
@@ -846,6 +1228,10 @@ defineExpose({
font-family: inherit;
min-height: 44px; /* Default min-height for multi-line */
max-height: 200px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ cursor: text;
&:focus {
outline: none;
@@ -855,6 +1241,58 @@ defineExpose({
&::placeholder {
color: var(--gray-400);
}
+
+ &.mention-editor {
+ position: relative;
+
+ &:empty::before {
+ content: attr(data-placeholder);
+ position: absolute;
+ left: 0;
+ top: 0;
+ color: var(--gray-400);
+ pointer-events: none;
+ }
+ }
+
+ &[contenteditable='false'] {
+ cursor: not-allowed;
+ }
+
+ :deep(.mention-ref-token) {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 2px;
+ max-width: min(100%, 360px);
+ color: var(--main-700);
+ line-height: normal;
+ vertical-align: baseline;
+ white-space: nowrap;
+ user-select: all;
+ }
+
+ :deep(.mention-ref-icon) {
+ position: relative;
+ top: 2px;
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ font-size: 13px;
+ line-height: 1;
+ margin-left: 4px;
+ }
+
+ :deep(.mention-ref-icon svg) {
+ display: block;
+ }
+
+ :deep(.mention-ref-label) {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: normal;
+ font-weight: 500;
+ }
}
.send-button-container {
@@ -992,6 +1430,7 @@ defineExpose({
0 -4px 16px rgba(0, 0, 0, 0.08),
0 4px 16px rgba(0, 0, 0, 0.12);
border: 1px solid var(--gray-200);
+ padding: 8px 0;
.mention-group {
margin-bottom: 4px;
@@ -1021,6 +1460,35 @@ defineExpose({
margin: 1px 4px;
border-radius: 4px;
+ &.resource-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ padding: 6px 10px;
+
+ .resource-name {
+ color: var(--gray-800);
+ font-weight: 500;
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .resource-description {
+ color: var(--gray-500);
+ font-size: 12px;
+ line-height: 1.35;
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
&.file-item {
display: flex;
flex-direction: row;
@@ -1065,11 +1533,42 @@ defineExpose({
}
}
- &:hover,
- &.active {
+ &:hover {
background-color: var(--main-10);
color: var(--main-600);
+ &.resource-item {
+ .resource-name {
+ color: var(--main-600);
+ }
+ .resource-description {
+ color: var(--main-400);
+ }
+ }
+
+ &.file-item {
+ .file-info-left .file-name {
+ color: var(--main-600);
+ }
+ .file-parent-dir {
+ color: var(--main-400);
+ }
+ }
+ }
+
+ &.active {
+ background-color: var(--gray-50);
+ color: var(--main-600);
+
+ &.resource-item {
+ .resource-name {
+ color: var(--main-600);
+ }
+ .resource-description {
+ color: var(--main-400);
+ }
+ }
+
&.file-item {
.file-info-left .file-name {
color: var(--main-600);
diff --git a/web/src/components/common/MentionTextRenderer.vue b/web/src/components/common/MentionTextRenderer.vue
new file mode 100644
index 00000000..e8ad3bd7
--- /dev/null
+++ b/web/src/components/common/MentionTextRenderer.vue
@@ -0,0 +1,121 @@
+
+
+
+ {{ segment.text }}
+
+
+ {{ getTokenLabel(segment) }}
+
+
+
+
+
+
+
+
diff --git a/web/src/utils/mention_utils.js b/web/src/utils/mention_utils.js
new file mode 100644
index 00000000..5ee5b1a9
--- /dev/null
+++ b/web/src/utils/mention_utils.js
@@ -0,0 +1,184 @@
+import { getDisplayFileName } from '@/utils/file_utils'
+
+export const mentionTypePrefixMap = {
+ file: 'file',
+ knowledge: 'knowledge',
+ mcp: 'mcp',
+ skill: 'skill',
+ subagent: 'subagent'
+}
+
+const mentionTypePattern = Object.values(mentionTypePrefixMap).join('|')
+const mentionTokenRegex = new RegExp(`@(${mentionTypePattern}):\\S+`, 'g')
+
+export const formatMentionToken = (type, value) => {
+ const prefix = mentionTypePrefixMap[type] || type
+ return `@${prefix}:${value}`
+}
+
+export const parseMentionText = (text = '') => {
+ const value = String(text || '')
+ const segments = []
+ let lastIndex = 0
+
+ mentionTokenRegex.lastIndex = 0
+ for (const match of value.matchAll(mentionTokenRegex)) {
+ const raw = match[0]
+ const start = match.index ?? 0
+ const end = start + raw.length
+
+ if (start > lastIndex) {
+ segments.push({
+ kind: 'text',
+ text: value.slice(lastIndex, start),
+ start: lastIndex,
+ end: start
+ })
+ }
+
+ const type = match[1]
+ const prefix = `@${type}:`
+ segments.push({
+ kind: 'mention',
+ raw,
+ type,
+ value: raw.slice(prefix.length),
+ start,
+ end
+ })
+
+ lastIndex = end
+ }
+
+ if (lastIndex < value.length) {
+ segments.push({
+ kind: 'text',
+ text: value.slice(lastIndex),
+ start: lastIndex,
+ end: value.length
+ })
+ }
+
+ return segments
+}
+
+const setMentionLabel = (labels, type, value, label) => {
+ const rawValue = String(value || '').trim()
+ const displayLabel = String(label || '').trim()
+ if (!rawValue || !displayLabel) return
+ labels[`${type}:${rawValue}`] = displayLabel
+}
+
+export const buildMentionDisplayLabels = (mention = {}) => {
+ const labels = {}
+
+ ;(mention.knowledgeBases || []).forEach((kb) => {
+ const label = kb?.name || kb?.label || kb?.kb_id || kb?.value || ''
+ setMentionLabel(labels, 'knowledge', kb?.name, label)
+ setMentionLabel(labels, 'knowledge', kb?.label, label)
+ setMentionLabel(labels, 'knowledge', kb?.kb_id, label)
+ setMentionLabel(labels, 'knowledge', kb?.value, label)
+ })
+
+ ;(mention.mcps || []).forEach((mcp) => {
+ const label = mcp?.name || mcp?.label || mcp?.slug || mcp?.id || mcp?.value || ''
+ setMentionLabel(labels, 'mcp', mcp?.slug, label)
+ setMentionLabel(labels, 'mcp', mcp?.id, label)
+ setMentionLabel(labels, 'mcp', mcp?.value, label)
+ setMentionLabel(labels, 'mcp', mcp?.name, label)
+ })
+
+ ;(mention.skills || []).forEach((skill) => {
+ const label = skill?.name || skill?.label || skill?.slug || skill?.id || skill?.value || ''
+ setMentionLabel(labels, 'skill', skill?.slug, label)
+ setMentionLabel(labels, 'skill', skill?.id, label)
+ setMentionLabel(labels, 'skill', skill?.value, label)
+ setMentionLabel(labels, 'skill', skill?.name, label)
+ })
+
+ ;(mention.subagents || []).forEach((subagent) => {
+ const label = subagent?.name || subagent?.label || subagent?.id || subagent?.value || ''
+ setMentionLabel(labels, 'subagent', subagent?.id, label)
+ setMentionLabel(labels, 'subagent', subagent?.value, label)
+ setMentionLabel(labels, 'subagent', subagent?.slug, label)
+ setMentionLabel(labels, 'subagent', subagent?.name, label)
+ })
+
+ return labels
+}
+
+export const getMentionDisplayLabel = (type, value, displayLabels = {}) => {
+ const mappedLabel = displayLabels[`${type}:${value}`]
+ if (mappedLabel) return mappedLabel
+
+ if (type === 'file') {
+ const normalizedPath = String(value || '').replace(/\/+$/, '')
+ return getDisplayFileName(normalizedPath || value, '文件')
+ }
+ return String(value || '').trim() || type
+}
+
+export const findActiveMentionQuery = (text = '', rawCaretOffset = 0) => {
+ const value = String(text || '')
+ const offset = Math.max(0, Math.min(rawCaretOffset, value.length))
+ const segments = parseMentionText(value)
+ const mentionAtCursor = segments.find(
+ (segment) => segment.kind === 'mention' && offset > segment.start && offset <= segment.end
+ )
+
+ if (mentionAtCursor) return null
+
+ const textBeforeCursor = value.slice(0, offset)
+ const atIndex = textBeforeCursor.lastIndexOf('@')
+ if (atIndex === -1) return null
+
+ const query = textBeforeCursor.slice(atIndex + 1)
+ if (query.includes('@') || query.includes(':') || /\s/.test(query)) return null
+
+ return { start: atIndex, end: offset, query }
+}
+
+export const replaceRawRange = (text = '', start = 0, end = start, replacement = '') => {
+ const value = String(text || '')
+ const safeStart = Math.max(0, Math.min(start, value.length))
+ const safeEnd = Math.max(safeStart, Math.min(end, value.length))
+ return value.slice(0, safeStart) + replacement + value.slice(safeEnd)
+}
+
+export const expandMentionDeletionRange = (text = '', start = 0, end = start, direction = 'backward') => {
+ const value = String(text || '')
+ const safeStart = Math.max(0, Math.min(start, value.length))
+ const safeEnd = Math.max(safeStart, Math.min(end, value.length))
+ const mentions = parseMentionText(value).filter((segment) => segment.kind === 'mention')
+
+ if (safeStart !== safeEnd) {
+ const touchedMentions = mentions.filter(
+ (segment) => segment.start < safeEnd && segment.end > safeStart
+ )
+ if (!touchedMentions.length) return null
+
+ return {
+ start: Math.min(safeStart, ...touchedMentions.map((segment) => segment.start)),
+ end: Math.max(safeEnd, ...touchedMentions.map((segment) => segment.end))
+ }
+ }
+
+ const cursor = safeStart
+ if (direction === 'forward') {
+ const mention = mentions.find((segment) => cursor >= segment.start && cursor < segment.end)
+ if (!mention) return null
+
+ const includeTrailingSpace = value[mention.end] === ' '
+ return {
+ start: mention.start,
+ end: includeTrailingSpace ? mention.end + 1 : mention.end
+ }
+ }
+
+ const mention = mentions.find((segment) => cursor > segment.start && cursor <= segment.end)
+ if (mention) {
+ return { start: mention.start, end: mention.end }
+ }
+
+ return null
+}