diff --git a/src/pages/chat/ChatPage.tsx b/src/pages/chat/ChatPage.tsx index 3062c61..dc671b1 100644 --- a/src/pages/chat/ChatPage.tsx +++ b/src/pages/chat/ChatPage.tsx @@ -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={() => {}} /> )} diff --git a/src/pages/chat/components/ChatInput.tsx b/src/pages/chat/components/ChatInput.tsx index 113e131..a1cf3bd 100644 --- a/src/pages/chat/components/ChatInput.tsx +++ b/src/pages/chat/components/ChatInput.tsx @@ -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(null); + + // 检测 @ 触发提及选择 + const handleInputChange = (e: React.ChangeEvent) => { + 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 (
@@ -79,9 +143,10 @@ export default function ChatInput(props: {
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 && ( +
+ {filteredAgents.length === 0 ? ( +
+ 未找到匹配的智能体 +
+ ) : ( + filteredAgents.map(agent => ( +
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'} + > +
+ {agent.name} +
+ {agent.description && ( +
+ {agent.description.slice(0, 30)} + {agent.description.length > 30 ? '...' : ''} +
+ )} +
+ )) + )} +
+ )}
diff --git a/src/pages/chat/components/messages/MessageItem.tsx b/src/pages/chat/components/messages/MessageItem.tsx index 5dfad1b..21e2425 100644 --- a/src/pages/chat/components/messages/MessageItem.tsx +++ b/src/pages/chat/components/messages/MessageItem.tsx @@ -48,7 +48,9 @@ export default function MessageItem(props: { ) : message.content.includes('![image](') ? ( {message.content} ) : ( - message.content + @$1') + }} /> )}
diff --git a/src/pages/chat/hooks/useChatSender.ts b/src/pages/chat/hooks/useChatSender.ts index cd449b2..b45a74a 100644 --- a/src/pages/chat/hooks/useChatSender.ts +++ b/src/pages/chat/hooks/useChatSender.ts @@ -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([]); diff --git a/src/styles.css b/src/styles.css index 2f1d802..aaebdbc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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;