ForcePilot/web/src/utils/mention_utils.js
2026-05-31 13:40:15 +08:00

201 lines
6.3 KiB
JavaScript

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'
)
const quoteMentionValue = (value) =>
String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
const unquoteMentionValue = (value) => String(value ?? '').replace(/\\(["\\])/g, '$1')
export const formatMentionToken = (type, value) => {
const prefix = mentionTypePrefixMap[type] || type
const rawValue = String(value ?? '')
if (/\s|["\\]/.test(rawValue)) {
return `@${prefix}:"${quoteMentionValue(rawValue)}"`
}
return `@${prefix}:${rawValue}`
}
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 quotedValue = match[2]
const rawValue = match[3]
segments.push({
kind: 'mention',
raw,
type,
value: quotedValue !== undefined ? unquoteMentionValue(quotedValue) : rawValue,
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
}