feature: support @mention other agent to answer in current session
parent
d30150f94b
commit
92799189e2
|
|
@ -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={() => {}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ export default function MessageItem(props: {
|
|||
) : message.content.includes(' ? (
|
||||
<Markdown>{message.content}</Markdown>
|
||||
) : (
|
||||
message.content
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: message.content.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue