feat(group-chat): add @all mention routing (#857)
Add modular group-chat mention routing helpers for the reserved @all token, route it to every non-sender agent, and strip routing tokens before model input. Expose @all in mention autocomplete, highlight it in group messages, reserve literal all agent names, and cover boundary/partial-match regressions with tests.
This commit is contained in:
@@ -137,8 +137,10 @@ const renderedHtml = computed(() => {
|
||||
})
|
||||
|
||||
if (props.mentionNames && props.mentionNames.length > 0) {
|
||||
const escaped = props.mentionNames.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
const re = new RegExp(`(?<=[\\s>]|^)@(${escaped.join('|')})(?=\\s|$)`, 'gi')
|
||||
const escaped = [...props.mentionNames]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
const re = new RegExp(`(?<=[\\s>({\\[<]|^)@(${escaped.join('|')})(?=[\\s.,!?;:,。!?;:)\\]}>]|<|$)`, 'gi')
|
||||
html = html.replace(re, '<span class="mention-highlight">@$1</span>')
|
||||
}
|
||||
return html
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NSwitch, NTooltip } from 'naive-ui'
|
||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
import { buildMentionOptions, type MentionOption } from './mention-options'
|
||||
import type { Attachment } from '@/stores/hermes/chat'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -74,10 +75,7 @@ const dropdownBottom = ref(0)
|
||||
const placement = ref<'bottom' | 'top'>('bottom')
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
const query = mentionQuery.value.toLowerCase()
|
||||
return store.agents.filter(a => a.name.toLowerCase().includes(query))
|
||||
})
|
||||
const filteredMentionOptions = computed(() => buildMentionOptions(store.agents, mentionQuery.value))
|
||||
|
||||
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
|
||||
|
||||
@@ -146,7 +144,7 @@ function updateMentionState() {
|
||||
dropdownX.value = rect.left + mirrorRect.width - el.scrollLeft
|
||||
|
||||
// Decide placement: if dropdown would go below viewport, flip upward
|
||||
const estimatedHeight = Math.min(filteredAgents.value.length * 36 + 8, 240)
|
||||
const estimatedHeight = Math.min(filteredMentionOptions.value.length * 36 + 8, 240)
|
||||
const spaceBelow = window.innerHeight - rect.top + el.scrollTop - 8
|
||||
if (spaceBelow < estimatedHeight && rect.top - el.scrollTop - 8 > estimatedHeight) {
|
||||
placement.value = 'top'
|
||||
@@ -158,7 +156,7 @@ function updateMentionState() {
|
||||
|
||||
dropdownBottom.value = window.innerHeight - dropdownY.value
|
||||
|
||||
mentionActive.value = filteredAgents.value.length > 0
|
||||
mentionActive.value = filteredMentionOptions.value.length > 0
|
||||
}
|
||||
|
||||
function selectMention(name: string) {
|
||||
@@ -187,22 +185,22 @@ function selectMention(name: string) {
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Mention navigation — fully custom, no NDropdown interference
|
||||
if (mentionActive.value && filteredAgents.value.length > 0) {
|
||||
if (mentionActive.value && filteredMentionOptions.value.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % filteredAgents.value.length
|
||||
activeIndex.value = (activeIndex.value + 1) % filteredMentionOptions.value.length
|
||||
scrollToActive()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value - 1 + filteredAgents.value.length) % filteredAgents.value.length
|
||||
activeIndex.value = (activeIndex.value - 1 + filteredMentionOptions.value.length) % filteredMentionOptions.value.length
|
||||
scrollToActive()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
selectMention(filteredAgents.value[activeIndex.value].name)
|
||||
selectMention(filteredMentionOptions.value[activeIndex.value].name)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
@@ -242,8 +240,8 @@ function handleInput(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleMentionClick(name: string) {
|
||||
selectMention(name)
|
||||
function handleMentionClick(option: MentionOption) {
|
||||
selectMention(option.name)
|
||||
}
|
||||
|
||||
function handleMentionHover(index: number) {
|
||||
@@ -449,7 +447,7 @@ function isImage(type: string): boolean {
|
||||
</div>
|
||||
<Transition name="dropdown-fade">
|
||||
<div
|
||||
v-if="mentionActive && filteredAgents.length > 0"
|
||||
v-if="mentionActive && filteredMentionOptions.length > 0"
|
||||
ref="dropdownRef"
|
||||
class="mention-dropdown"
|
||||
:class="{ 'placement-top': placement === 'top' }"
|
||||
@@ -460,15 +458,15 @@ function isImage(type: string): boolean {
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(agent, i) in filteredAgents"
|
||||
:key="agent.name"
|
||||
v-for="(option, i) in filteredMentionOptions"
|
||||
:key="option.key"
|
||||
class="mention-dropdown-item"
|
||||
:class="{ active: i === activeIndex }"
|
||||
@mousedown.prevent="handleMentionClick(agent.name)"
|
||||
:class="{ active: i === activeIndex, 'mention-all-option': option.type === 'all' }"
|
||||
@mousedown.prevent="handleMentionClick(option)"
|
||||
@mouseenter="handleMentionHover(i)"
|
||||
>
|
||||
<span class="mention-name">@{{ agent.name }}</span>
|
||||
<span class="mention-profile">{{ agent.profile }}</span>
|
||||
<span class="mention-name">{{ option.label }}</span>
|
||||
<span class="mention-profile">{{ option.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -743,6 +741,11 @@ function isImage(type: string): boolean {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.mention-all-option .mention-name {
|
||||
color: $accent-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dropdown fade/scale animation (matching NDropdown) ── */
|
||||
|
||||
@@ -56,7 +56,7 @@ const avatarSvg = computed(() => {
|
||||
return multiavatar(props.message.senderName || props.message.senderId)
|
||||
})
|
||||
|
||||
const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean))
|
||||
const mentionNames = computed(() => ['all', ...props.agents.map(a => a.name).filter(Boolean)])
|
||||
const parsedThinking = computed(() => parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming }))
|
||||
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0))
|
||||
const hasThinking = computed(() => hasReasoningField.value || parsedThinking.value.hasThinking)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export type MentionOption = {
|
||||
key: string
|
||||
type: 'all' | 'agent'
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type MentionAgent = {
|
||||
name: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
function isReservedMentionName(name: string): boolean {
|
||||
return name.trim().toLowerCase() === 'all'
|
||||
}
|
||||
|
||||
export function buildMentionOptions(agents: MentionAgent[], query: string): MentionOption[] {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const options: MentionOption[] = []
|
||||
|
||||
if (!normalizedQuery || 'all'.includes(normalizedQuery)) {
|
||||
options.push({
|
||||
key: 'special:all',
|
||||
type: 'all',
|
||||
name: 'all',
|
||||
label: '@all',
|
||||
description: 'All agents',
|
||||
})
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
const agentName = agent.name || ''
|
||||
if (isReservedMentionName(agentName)) continue
|
||||
if (!agentName.toLowerCase().includes(normalizedQuery)) continue
|
||||
options.push({
|
||||
key: `agent:${agentName}`,
|
||||
type: 'agent',
|
||||
name: agentName,
|
||||
label: `@${agentName}`,
|
||||
description: agent.profile || '',
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
Reference in New Issue
Block a user