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) {
|
if (props.mentionNames && props.mentionNames.length > 0) {
|
||||||
const escaped = props.mentionNames.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
const escaped = [...props.mentionNames]
|
||||||
const re = new RegExp(`(?<=[\\s>]|^)@(${escaped.join('|')})(?=\\s|$)`, 'gi')
|
.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>')
|
html = html.replace(re, '<span class="mention-highlight">@$1</span>')
|
||||||
}
|
}
|
||||||
return html
|
return html
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { NButton, NSwitch, NTooltip } from 'naive-ui'
|
import { NButton, NSwitch, NTooltip } from 'naive-ui'
|
||||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||||
|
import { buildMentionOptions, type MentionOption } from './mention-options'
|
||||||
import type { Attachment } from '@/stores/hermes/chat'
|
import type { Attachment } from '@/stores/hermes/chat'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -74,10 +75,7 @@ const dropdownBottom = ref(0)
|
|||||||
const placement = ref<'bottom' | 'top'>('bottom')
|
const placement = ref<'bottom' | 'top'>('bottom')
|
||||||
const activeIndex = ref(0)
|
const activeIndex = ref(0)
|
||||||
|
|
||||||
const filteredAgents = computed(() => {
|
const filteredMentionOptions = computed(() => buildMentionOptions(store.agents, mentionQuery.value))
|
||||||
const query = mentionQuery.value.toLowerCase()
|
|
||||||
return store.agents.filter(a => a.name.toLowerCase().includes(query))
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
|
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
|
||||||
|
|
||||||
@@ -146,7 +144,7 @@ function updateMentionState() {
|
|||||||
dropdownX.value = rect.left + mirrorRect.width - el.scrollLeft
|
dropdownX.value = rect.left + mirrorRect.width - el.scrollLeft
|
||||||
|
|
||||||
// Decide placement: if dropdown would go below viewport, flip upward
|
// 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
|
const spaceBelow = window.innerHeight - rect.top + el.scrollTop - 8
|
||||||
if (spaceBelow < estimatedHeight && rect.top - el.scrollTop - 8 > estimatedHeight) {
|
if (spaceBelow < estimatedHeight && rect.top - el.scrollTop - 8 > estimatedHeight) {
|
||||||
placement.value = 'top'
|
placement.value = 'top'
|
||||||
@@ -158,7 +156,7 @@ function updateMentionState() {
|
|||||||
|
|
||||||
dropdownBottom.value = window.innerHeight - dropdownY.value
|
dropdownBottom.value = window.innerHeight - dropdownY.value
|
||||||
|
|
||||||
mentionActive.value = filteredAgents.value.length > 0
|
mentionActive.value = filteredMentionOptions.value.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectMention(name: string) {
|
function selectMention(name: string) {
|
||||||
@@ -187,22 +185,22 @@ function selectMention(name: string) {
|
|||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
// Mention navigation — fully custom, no NDropdown interference
|
// 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') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
activeIndex.value = (activeIndex.value + 1) % filteredAgents.value.length
|
activeIndex.value = (activeIndex.value + 1) % filteredMentionOptions.value.length
|
||||||
scrollToActive()
|
scrollToActive()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
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()
|
scrollToActive()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
selectMention(filteredAgents.value[activeIndex.value].name)
|
selectMention(filteredMentionOptions.value[activeIndex.value].name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -242,8 +240,8 @@ function handleInput(e: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMentionClick(name: string) {
|
function handleMentionClick(option: MentionOption) {
|
||||||
selectMention(name)
|
selectMention(option.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMentionHover(index: number) {
|
function handleMentionHover(index: number) {
|
||||||
@@ -449,7 +447,7 @@ function isImage(type: string): boolean {
|
|||||||
</div>
|
</div>
|
||||||
<Transition name="dropdown-fade">
|
<Transition name="dropdown-fade">
|
||||||
<div
|
<div
|
||||||
v-if="mentionActive && filteredAgents.length > 0"
|
v-if="mentionActive && filteredMentionOptions.length > 0"
|
||||||
ref="dropdownRef"
|
ref="dropdownRef"
|
||||||
class="mention-dropdown"
|
class="mention-dropdown"
|
||||||
:class="{ 'placement-top': placement === 'top' }"
|
:class="{ 'placement-top': placement === 'top' }"
|
||||||
@@ -460,15 +458,15 @@ function isImage(type: string): boolean {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(agent, i) in filteredAgents"
|
v-for="(option, i) in filteredMentionOptions"
|
||||||
:key="agent.name"
|
:key="option.key"
|
||||||
class="mention-dropdown-item"
|
class="mention-dropdown-item"
|
||||||
:class="{ active: i === activeIndex }"
|
:class="{ active: i === activeIndex, 'mention-all-option': option.type === 'all' }"
|
||||||
@mousedown.prevent="handleMentionClick(agent.name)"
|
@mousedown.prevent="handleMentionClick(option)"
|
||||||
@mouseenter="handleMentionHover(i)"
|
@mouseenter="handleMentionHover(i)"
|
||||||
>
|
>
|
||||||
<span class="mention-name">@{{ agent.name }}</span>
|
<span class="mention-name">{{ option.label }}</span>
|
||||||
<span class="mention-profile">{{ agent.profile }}</span>
|
<span class="mention-profile">{{ option.description }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -743,6 +741,11 @@ function isImage(type: string): boolean {
|
|||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.mention-all-option .mention-name {
|
||||||
|
color: $accent-primary;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dropdown fade/scale animation (matching NDropdown) ── */
|
/* ── Dropdown fade/scale animation (matching NDropdown) ── */
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const avatarSvg = computed(() => {
|
|||||||
return multiavatar(props.message.senderName || props.message.senderId)
|
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 parsedThinking = computed(() => parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming }))
|
||||||
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0))
|
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0))
|
||||||
const hasThinking = computed(() => hasReasoningField.value || parsedThinking.value.hasThinking)
|
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
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import type { GroupChatServer } from '../../services/hermes/group-chat'
|
import type { GroupChatServer } from '../../services/hermes/group-chat'
|
||||||
|
import { isReservedMentionName } from '../../services/hermes/group-chat/mention-routing'
|
||||||
|
|
||||||
export const groupChatRoutes = new Router()
|
export const groupChatRoutes = new Router()
|
||||||
|
|
||||||
@@ -45,6 +46,12 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
|
|||||||
ctx.body = { error: 'name and inviteCode are required' }
|
ctx.body = { error: 'name and inviteCode are required' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const reservedAgent = (agents || []).find(a => isReservedMentionName(a.name || a.profile))
|
||||||
|
if (reservedAgent) {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: '`all` is reserved for @all mentions' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const roomId = generateId()
|
const roomId = generateId()
|
||||||
const storage = chatServer.getStorage()
|
const storage = chatServer.getStorage()
|
||||||
@@ -213,6 +220,11 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx)
|
|||||||
ctx.body = { error: 'profile is required' }
|
ctx.body = { error: 'profile is required' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (isReservedMentionName(name || profile)) {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: '`all` is reserved for @all mentions' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent duplicate agent in same room
|
// Prevent duplicate agent in same room
|
||||||
const existing = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
|
const existing = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { updateUsage } from '../../../db/hermes/usage-store'
|
|||||||
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
|
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
|
||||||
import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks'
|
import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks'
|
||||||
import type { ContentBlock } from '../run-chat/types'
|
import type { ContentBlock } from '../run-chat/types'
|
||||||
|
import {
|
||||||
|
isAllAgentsMentioned,
|
||||||
|
resolveMentionTargets,
|
||||||
|
stripMentionRoutingTokens,
|
||||||
|
} from './mention-routing'
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -302,20 +307,20 @@ class AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the original mentions visible and add an explicit routing note.
|
// Keep routing explicit while removing only the mention tokens that
|
||||||
// When a user mentions multiple agents, stripping only this agent's
|
// selected this agent. This avoids making @all look like an
|
||||||
// name can make the remaining input look like it was meant for
|
// instruction for the model to fan out another routing cycle.
|
||||||
// someone else.
|
const routedPrefix = isAllAgentsMentioned(msg.content)
|
||||||
const routedPrefix = `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
|
? `群聊系统:这条消息通过 @all 提及所有 agent,你是其中之一,请直接回复。`
|
||||||
const ownMentionPattern = new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
|
: `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
|
||||||
const rawInput = msg.input || msg.content
|
const rawInput = msg.input || msg.content
|
||||||
const input = isContentBlockArray(rawInput)
|
const input = isContentBlockArray(rawInput)
|
||||||
? rawInput.map((block) => {
|
? rawInput.map((block) => {
|
||||||
if (block.type !== 'text') return block
|
if (block.type !== 'text') return block
|
||||||
const text = String(block.text || msg.content).replace(ownMentionPattern, '').trim()
|
const text = stripMentionRoutingTokens(String(block.text || msg.content), this.name)
|
||||||
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
|
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
|
||||||
})
|
})
|
||||||
: `${routedPrefix}\n\n原始消息:${msg.content.replace(ownMentionPattern, '').trim() || msg.content}`
|
: `${routedPrefix}\n\n原始消息:${stripMentionRoutingTokens(msg.content, this.name) || msg.content}`
|
||||||
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
|
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
|
||||||
? await convertContentBlocksForAgent(input)
|
? await convertContentBlocksForAgent(input)
|
||||||
: input
|
: input
|
||||||
@@ -815,12 +820,7 @@ export class AgentClients {
|
|||||||
*/
|
*/
|
||||||
async processMentions(roomId: string, msg: MentionMessage): Promise<void> {
|
async processMentions(roomId: string, msg: MentionMessage): Promise<void> {
|
||||||
const agents = this.getAgents(roomId)
|
const agents = this.getAgents(roomId)
|
||||||
const senderName = msg.senderName.toLowerCase()
|
const mentioned = resolveMentionTargets(agents, msg.content, msg.senderId)
|
||||||
|
|
||||||
const mentioned = agents.filter(a => (
|
|
||||||
a.name.toLowerCase() !== senderName &&
|
|
||||||
isAgentMentioned(msg.content, a.name)
|
|
||||||
))
|
|
||||||
if (mentioned.length === 0) return
|
if (mentioned.length === 0) return
|
||||||
|
|
||||||
logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`)
|
logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`)
|
||||||
@@ -886,9 +886,3 @@ export class AgentClients {
|
|||||||
function nextMentionDepth(msg: MentionMessage): number {
|
function nextMentionDepth(msg: MentionMessage): number {
|
||||||
return Math.max(0, msg.mentionDepth || 0) + 1
|
return Math.max(0, msg.mentionDepth || 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAgentMentioned(content: string, agentName: string): boolean {
|
|
||||||
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
const pattern = new RegExp(`@${escaped}(?=$|\\s|[.,!?;:,。!?;:])`, 'i')
|
|
||||||
return pattern.test(content)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export const ALL_AGENTS_MENTION = 'all'
|
||||||
|
|
||||||
|
type MentionableAgent = {
|
||||||
|
name: string
|
||||||
|
id?: string
|
||||||
|
agentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MentionRange = {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const BEFORE_BOUNDARY = new Set(['(', '[', '{', '<'])
|
||||||
|
const AFTER_BOUNDARY = new Set(['.', ',', '!', '?', ';', ':', ',', '。', '!', '?', ';', ':', ')', ']', '}', '>'])
|
||||||
|
|
||||||
|
export function escapeMentionName(name: string): string {
|
||||||
|
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReservedMentionName(name: string): boolean {
|
||||||
|
return name.trim().toLowerCase() === ALL_AGENTS_MENTION
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBeforeBoundary(char: string | undefined): boolean {
|
||||||
|
return char === undefined || /\s/.test(char) || BEFORE_BOUNDARY.has(char)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAfterBoundary(char: string | undefined): boolean {
|
||||||
|
return char === undefined || /\s/.test(char) || AFTER_BOUNDARY.has(char)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMentionRanges(content: string, mentionName: string): MentionRange[] {
|
||||||
|
if (!content || !mentionName) return []
|
||||||
|
|
||||||
|
const contentLower = content.toLowerCase()
|
||||||
|
const mentionLower = mentionName.toLowerCase()
|
||||||
|
const ranges: MentionRange[] = []
|
||||||
|
let fromIndex = 0
|
||||||
|
|
||||||
|
while (fromIndex < content.length) {
|
||||||
|
const atIndex = contentLower.indexOf(`@${mentionLower}`, fromIndex)
|
||||||
|
if (atIndex === -1) break
|
||||||
|
|
||||||
|
const start = atIndex
|
||||||
|
const end = atIndex + mentionName.length + 1
|
||||||
|
if (isBeforeBoundary(content[start - 1]) && isAfterBoundary(content[end])) {
|
||||||
|
ranges.push({ start, end })
|
||||||
|
}
|
||||||
|
fromIndex = atIndex + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentMentioned(content: string, agentName: string): boolean {
|
||||||
|
return findMentionRanges(content, agentName).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllAgentsMentioned(content: string): boolean {
|
||||||
|
return isAgentMentioned(content, ALL_AGENTS_MENTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSenderAgent(agent: MentionableAgent, senderId: string): boolean {
|
||||||
|
return Boolean(senderId && (agent.id === senderId || agent.agentId === senderId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMentionTargets<T extends MentionableAgent>(
|
||||||
|
agents: T[],
|
||||||
|
content: string,
|
||||||
|
senderId: string,
|
||||||
|
): T[] {
|
||||||
|
const candidates = agents.filter((agent) => !isSenderAgent(agent, senderId))
|
||||||
|
|
||||||
|
if (isAllAgentsMentioned(content)) {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.filter((agent) => isAgentMentioned(content, agent.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripMentionRoutingTokens(content: string, ownAgentName: string): string {
|
||||||
|
const rangesByKey = new Map<string, MentionRange>()
|
||||||
|
for (const range of [
|
||||||
|
...findMentionRanges(content, ALL_AGENTS_MENTION),
|
||||||
|
...findMentionRanges(content, ownAgentName),
|
||||||
|
]) {
|
||||||
|
rangesByKey.set(`${range.start}:${range.end}`, range)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = [...rangesByKey.values()].sort((a, b) => b.start - a.start)
|
||||||
|
|
||||||
|
let result = content
|
||||||
|
for (const range of ranges) {
|
||||||
|
result = `${result.slice(0, range.start)}${result.slice(range.end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
.replace(/^[\s,,::;;.!?。!?]+/, '')
|
||||||
|
.replace(/[\s,,::;;]+$/g, '')
|
||||||
|
.replace(/[ \t]{2,}/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildMentionOptions } from '@/components/hermes/group-chat/mention-options'
|
||||||
|
|
||||||
|
describe('group chat mention options', () => {
|
||||||
|
const agents = [
|
||||||
|
{ name: 'Alice', profile: 'alice-profile' },
|
||||||
|
{ name: 'Bob', profile: 'bob-profile' },
|
||||||
|
{ name: 'all', profile: 'literal-all-agent' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('offers @all before agent mentions when the mention query is empty', () => {
|
||||||
|
expect(buildMentionOptions(agents, '').map(option => option.key)).toEqual([
|
||||||
|
'special:all',
|
||||||
|
'agent:Alice',
|
||||||
|
'agent:Bob',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps @all reserved when filtering by all and hides a literal all agent', () => {
|
||||||
|
expect(buildMentionOptions(agents, 'all')).toEqual([
|
||||||
|
{
|
||||||
|
key: 'special:all',
|
||||||
|
type: 'all',
|
||||||
|
name: 'all',
|
||||||
|
label: '@all',
|
||||||
|
description: 'All agents',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters normal agent mentions without showing @all for unrelated queries', () => {
|
||||||
|
expect(buildMentionOptions(agents, 'bo').map(option => option.key)).toEqual(['agent:Bob'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('naive-ui', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('naive-ui')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useMessage: () => ({
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/download', () => ({
|
||||||
|
downloadFile: vi.fn(),
|
||||||
|
getDownloadUrl: vi.fn((path: string) => `/download?path=${encodeURIComponent(path)}`),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/hermes/chat/mermaidRenderer', () => ({
|
||||||
|
renderMermaidDiagram: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||||
|
|
||||||
|
describe('MarkdownRenderer special mentions', () => {
|
||||||
|
it('highlights @all as a mention when provided by group chat', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '@all, please compare options',
|
||||||
|
mentionNames: ['all', 'Alice'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.mention-highlight').text()).toBe('@all')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights @all at the end of rendered paragraphs and after opening punctuation', () => {
|
||||||
|
for (const content of ['@all', 'please compare @all', '(@all)']) {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content,
|
||||||
|
mentionNames: ['all', 'Alice'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.mention-highlight').text()).toBe('@all')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not highlight @alligator as @all', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '@alligator should stay plain',
|
||||||
|
mentionNames: ['all'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.mention-highlight').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
isAllAgentsMentioned,
|
||||||
|
isAgentMentioned,
|
||||||
|
isReservedMentionName,
|
||||||
|
resolveMentionTargets,
|
||||||
|
stripMentionRoutingTokens,
|
||||||
|
} from '../../packages/server/src/services/hermes/group-chat/mention-routing'
|
||||||
|
|
||||||
|
type TestAgent = { name: string; id?: string; agentId?: string; profile?: string }
|
||||||
|
|
||||||
|
const agents: TestAgent[] = [
|
||||||
|
{ name: 'Alice', id: 'socket-alice', agentId: 'agent-alice' },
|
||||||
|
{ name: 'Bob', id: 'socket-bob', agentId: 'agent-bob' },
|
||||||
|
{ name: 'Regex.Bot', id: 'socket-regex', agentId: 'agent-regex' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('group chat mention routing', () => {
|
||||||
|
it('reserves @all so it cannot be confused with a literal agent name', () => {
|
||||||
|
expect(isReservedMentionName('all')).toBe(true)
|
||||||
|
expect(isReservedMentionName(' ALL ')).toBe(true)
|
||||||
|
expect(isReservedMentionName('Alice')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recognizes @all as a standalone mention with safe boundaries', () => {
|
||||||
|
expect(isAllAgentsMentioned('@all please compare notes')).toBe(true)
|
||||||
|
expect(isAllAgentsMentioned('please compare notes @ALL')).toBe(true)
|
||||||
|
expect(isAllAgentsMentioned('@all, compare notes')).toBe(true)
|
||||||
|
expect(isAllAgentsMentioned('email user@all.example')).toBe(false)
|
||||||
|
expect(isAllAgentsMentioned('@alligator should not notify everyone')).toBe(false)
|
||||||
|
expect(isAllAgentsMentioned('prefix@all should not notify everyone')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps exact agent mentions boundary-aware and regex-safe', () => {
|
||||||
|
expect(isAgentMentioned('@Regex.Bot please review', 'Regex.Bot')).toBe(true)
|
||||||
|
expect(isAgentMentioned('@RegexxBot should not match', 'Regex.Bot')).toBe(false)
|
||||||
|
expect(isAgentMentioned('@Alice, please review', 'Alice')).toBe(true)
|
||||||
|
expect(isAgentMentioned('mailto@Alice.example', 'Alice')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes @all to every room agent except the sender identity', () => {
|
||||||
|
expect(resolveMentionTargets(agents, '@all summarize the options', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps same-name human senders routable because sender exclusion uses identity, not display name', () => {
|
||||||
|
const sameNameAgents: TestAgent[] = [
|
||||||
|
{ name: 'test', id: 'socket-agent-test', agentId: 'agent-test' },
|
||||||
|
{ name: 'tt', id: 'socket-agent-tt', agentId: 'agent-tt' },
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(resolveMentionTargets(sameNameAgents, '@all can you talk to me?', 'human-test-user').map(a => a.name)).toEqual(['test', 'tt'])
|
||||||
|
expect(resolveMentionTargets(sameNameAgents, '@test why no response?', 'human-test-user').map(a => a.name)).toEqual(['test'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still excludes an agent from routing to itself when the sender identity matches that agent', () => {
|
||||||
|
const sameNameAgents: TestAgent[] = [
|
||||||
|
{ name: 'test', id: 'socket-agent-test', agentId: 'agent-test' },
|
||||||
|
{ name: 'tt', id: 'socket-agent-tt', agentId: 'agent-tt' },
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(resolveMentionTargets(sameNameAgents, '@all compare plans', 'socket-agent-test').map(a => a.name)).toEqual(['tt'])
|
||||||
|
expect(resolveMentionTargets(sameNameAgents, '@all compare plans', 'agent-test').map(a => a.name)).toEqual(['tt'])
|
||||||
|
expect(resolveMentionTargets(sameNameAgents, '@test check yourself', 'socket-agent-test').map(a => a.name)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes explicit mentions without treating partial @all text as broadcast', () => {
|
||||||
|
expect(resolveMentionTargets(agents, '@Bob and @Regex.Bot compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot'])
|
||||||
|
expect(resolveMentionTargets(agents, '@alligator and @Bob compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes mixed @all and explicit mentions', () => {
|
||||||
|
expect(resolveMentionTargets(agents, '@all @Bob compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips the broadcast token and this agent mention before routing to the model', () => {
|
||||||
|
expect(stripMentionRoutingTokens('@all @Bob please review', 'Bob')).toBe('please review')
|
||||||
|
expect(stripMentionRoutingTokens('@ALL, @Regex.Bot: please review', 'Regex.Bot')).toBe('please review')
|
||||||
|
expect(stripMentionRoutingTokens('@all please review', 'all')).toBe('please review')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user