feature: support @mention other agent to answer in current session

main
sp mac bookpro 2605 2026-06-07 20:19:17 +08:00
parent d30150f94b
commit 92799189e2
5 changed files with 219 additions and 14 deletions

View File

@ -114,6 +114,7 @@ export default function ChatPage() {
const sender = useChatSender({
agentId: id,
agent,
agentList,
sessionId,
overrides,
setOverrides: (updater) => setOverrides(updater),
@ -215,6 +216,8 @@ export default function ChatPage() {
message.error('创建会话失败:' + (e?.message ?? e));
}
}}
agentList={agentList}
onInsertMention={() => {}}
/>
</>
)}

View File

@ -1,14 +1,16 @@
import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons';
import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload } from 'antd';
import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd';
import type { ChatAttachment } from '../../../api';
import type { Agent } from '../../../api/agents';
import { HistoryIcon, NewChatIcon } from '../../../components/icons';
import { useState, useRef, useEffect } from 'react';
export default function ChatInput(props: {
input: string;
setInput: (v: string) => void;
sending: boolean;
attachments: ChatAttachment[];
setAttachments: (updater: (prev: ChatAttachment[]) => ChatAttachment[]) => void;
setAttachments: (updater: (prev: ChatAttachment[]) => ChatAttachment[]) => ChatAttachment[];
imageUrls: string[];
setImageUrls: (updater: (prev: string[]) => string[]) => void;
onSend: () => void;
@ -20,6 +22,8 @@ export default function ChatInput(props: {
onChangeModel: (modelId: string) => void;
onOpenHistory: () => void;
onNewSession: () => void;
agentList: Agent[];
onInsertMention: (agentName: string) => void;
}) {
const {
input,
@ -37,9 +41,69 @@ export default function ChatInput(props: {
activeModelValue,
onChangeModel,
onOpenHistory,
onNewSession
onNewSession,
agentList,
onInsertMention
} = props;
const [showMentionPopover, setShowMentionPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionPos, setMentionPos] = useState<{ top: number; left: number } | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// 检测 @ 触发提及选择
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setInput(value);
const cursorPos = e.target.selectionStart ?? value.length;
const textBeforeCursor = value.slice(0, cursorPos);
const atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
const query = textBeforeCursor.slice(atIndex + 1);
if (!query.includes(' ')) {
setMentionQuery(query);
// 计算位置
const textarea = e.target;
const rect = textarea.getBoundingClientRect();
setMentionPos({ top: rect.bottom, left: rect.left + 10 });
setShowMentionPopover(true);
return;
}
}
setShowMentionPopover(false);
};
const filteredAgents = agentList.filter(a =>
a.name.toLowerCase().includes(mentionQuery.toLowerCase())
);
const handleSelectAgent = (agent: Agent) => {
const textarea = inputRef.current;
if (!textarea) return;
const value = input;
const cursorPos = textarea.selectionStart ?? value.length;
const textBefore = value.slice(0, cursorPos);
const atIndex = textBefore.lastIndexOf('@');
if (atIndex === -1) {
setShowMentionPopover(false);
return;
}
const newValue = value.slice(0, atIndex) + `@${agent.name} ` + value.slice(cursorPos);
setInput(newValue);
setShowMentionPopover(false);
requestAnimationFrame(() => {
textarea.focus();
const newCursor = atIndex + agent.name.length + 2;
textarea.setSelectionRange(newCursor, newCursor);
});
};
return (
<div className="chat-input-wrapper">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
@ -79,9 +143,10 @@ export default function ChatInput(props: {
<div className="chat-input-card">
<div className="chat-input-stack">
<Input.TextArea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问我任何问题..."
onChange={handleInputChange}
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
autoSize={{ minRows: 3, maxRows: 10 }}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
@ -108,6 +173,55 @@ export default function ChatInput(props: {
className="chat-input-textarea"
disabled={sending}
/>
{showMentionPopover && mentionPos && (
<div
className="mention-popover"
style={{
position: 'fixed',
top: mentionPos.top,
left: mentionPos.left,
zIndex: 10000,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
maxHeight: 200,
overflowY: 'auto',
minWidth: 150
}}
>
{filteredAgents.length === 0 ? (
<div style={{ padding: 8, color: 'var(--color-text-tertiary)' }}>
</div>
) : (
filteredAgents.map(agent => (
<div
key={agent.id}
className="mention-item"
onClick={() => handleSelectAgent(agent)}
style={{
padding: '6px 10px',
cursor: 'pointer',
borderBottom: '1px solid var(--color-border)'
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-fill-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--color-text)' }}>
{agent.name}
</div>
{agent.description && (
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{agent.description.slice(0, 30)}
{agent.description.length > 30 ? '...' : ''}
</div>
)}
</div>
))
)}
</div>
)}
<div className="chat-input-toolbar">
<div className="chat-input-toolbar-left">

View File

@ -48,7 +48,9 @@ export default function MessageItem(props: {
) : message.content.includes('![image](') ? (
<Markdown>{message.content}</Markdown>
) : (
message.content
<span dangerouslySetInnerHTML={{
__html: message.content.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
}} />
)}
</div>

View File

@ -14,9 +14,56 @@ export interface StreamingState {
toolCalls: ToolCallTrace[];
}
// 解析文本中的 @AgentName返回找到的目标 Agent
export function parseMentionedAgent(text: string, agentList: Agent[]): Agent | null {
// 匹配 @ 开头,空格/结尾结束的名称
const mentionRegex = /@([^\s]+)/g;
let match: RegExpExecArray | null;
let lastMention: string | null = null;
while ((match = mentionRegex.exec(text)) !== null) {
lastMention = match[1];
}
// 如果一行开头就是 @AgentName优先使用它最常见情况
const firstLine = text.split('\n')[0];
const startMatch = firstLine.match(/^@([^\s]+)/);
const candidateName = startMatch ? startMatch[1] : lastMention;
if (!candidateName) return null;
// 模糊匹配(前缀匹配,不区分大小写)
const lowerCandidate = candidateName.toLowerCase();
let bestMatch: Agent | null = null;
let bestScore = 0;
for (const a of agentList) {
const lowerName = a.name.toLowerCase();
if (lowerName === lowerCandidate) {
return a; // 精确匹配直接返回
}
if (lowerName.startsWith(lowerCandidate)) {
const score = candidateName.length / lowerName.length;
if (score > bestScore) {
bestScore = score;
bestMatch = a;
}
} else if (lowerName.includes(lowerCandidate)) {
const score = candidateName.length / lowerName.length * 0.8; // 包含匹配权重稍低
if (score > bestScore) {
bestScore = score;
bestMatch = a;
}
}
}
return bestScore > 0.3 ? bestMatch : null;
}
export function useChatSender(args: {
agentId?: string;
agent: Agent | null;
agentList: Agent[];
sessionId: string | null;
overrides: ModelOverrides;
setOverrides: (updater: (prev: ModelOverrides) => ModelOverrides) => void;
@ -28,7 +75,7 @@ export function useChatSender(args: {
notify: { success: (t: string) => void; error: (t: string) => void };
abortRef: { current: AbortController | null };
}) {
const { agentId, agent, sessionId, overrides, setOverrides, setBranches, loadMessages, scrollBottom, notify, abortRef } = args;
const { agentId, agent, agentList, sessionId, overrides, setOverrides, setBranches, loadMessages, scrollBottom, notify, abortRef } = args;
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [useStream, setUseStream] = useState(true);
@ -65,14 +112,28 @@ export function useChatSender(args: {
const ctrl = new AbortController();
abortRef.current = ctrl;
const model = overrides.model || agentModels[0]?.name || '';
const modelId = overrides.model_id || agentModels[0]?.id || '';
// 解析 @提及的目标 Agent
const targetAgent = parseMentionedAgent(text, agentList);
const targetAgentId = targetAgent?.id || agentId;
// 如果提及了其他 Agent使用该 Agent 的第一个模型
let targetModel: string;
let targetModelId: string;
if (targetAgent && targetAgent.id !== agentId) {
const models = parseAgentModels(targetAgent.model);
targetModel = models[0]?.name || '';
targetModelId = models[0]?.id || '';
} else {
targetModel = overrides.model || agentModels[0]?.name || '';
targetModelId = overrides.model_id || agentModels[0]?.id || '';
}
const attText = buildAttachmentsText(attachments);
const content = attText ? `${text}\n\n${attText}` : text;
try {
await streamChat(
agentId,
targetAgentId,
content,
{
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
@ -149,8 +210,8 @@ export function useChatSender(args: {
},
ctrl.signal,
sessionId,
model,
modelId,
targetModel,
targetModelId,
imageUrls
);
} catch (e: any) {
@ -178,11 +239,24 @@ export function useChatSender(args: {
const tempUser: ChatMessage = { id: 'tmp-' + Date.now(), role: 'user', content: text, createdAt: Date.now() };
args.setMessages((m) => [...(m || []), tempUser]);
scrollBottom(true);
// 解析 @提及的目标 Agent
const targetAgent = parseMentionedAgent(text, agentList);
const targetAgentId = targetAgent?.id || agentId;
// 如果提及了其他 Agent使用该 Agent 的第一个模型
let targetModel: string;
if (targetAgent && targetAgent.id !== agentId) {
const models = parseAgentModels(targetAgent.model);
targetModel = models[0]?.name || '';
} else {
targetModel = overrides.model || agentModels[0]?.name || '';
}
const attText = buildAttachmentsText(attachments);
const content = attText ? `${text}\n\n${attText}` : text;
const model = overrides.model || agentModels[0]?.name || '';
try {
const res = await ChatAPI.send(agentId, content, sessionId, model, imageUrls);
const res = await ChatAPI.send(targetAgentId, content, sessionId, targetModel, imageUrls);
args.setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), res.user, res.assistant]);
setSessionRefresh((t) => t + 1);
setAttachments([]);

View File

@ -1569,6 +1569,18 @@ body {
padding: 8px 8px !important;
}
/* @ Mention highlighting */
span.mention {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.25);
color: #3b82f6;
padding: 1px 4px;
border-radius: 4px;
font-weight: 500;
display: inline-block;
line-height: 1.3;
}
.ant-collapse-content {
background: var(--color-surface) !important;
color: var(--color-text) !important;