From 965f133ed7d2f38d7d6c255b83fc3ac055e5112b Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 29 May 2026 00:08:43 +0800 Subject: [PATCH] feat(webui): add agent picker and mentions Co-authored-by: Cursor --- .../src/components/common/SessionChat.test.ts | 69 +++++- webui/src/components/common/SessionChat.tsx | 211 ++++++++++++++++-- webui/src/locales/en-US/session.json | 19 ++ webui/src/locales/zh-CN/session.json | 19 ++ webui/src/pages/Session/index.test.tsx | 157 ++++++++++++- webui/src/pages/Session/index.tsx | 119 ++++++++-- 6 files changed, 556 insertions(+), 38 deletions(-) diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index f6b9ad1c3..0f1336739 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -1,5 +1,6 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Message } from '@/types'; @@ -30,6 +31,10 @@ const tMock = (key: string) => ({ 'chat.thinking': '思考中...', 'chat.streaming': '继续输出中...', 'chat.compacting': '压缩中...', + 'chat.mention.title': '选择 Agent', + 'chat.mention.navigate': '导航', + 'chat.mention.select': '选择', + 'smartAssistant': '智能助手', }[key] ?? key); const pendingQuestionsHookMock = { pendingQuestions: {}, @@ -49,6 +54,7 @@ const toastMock = { vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: tMock, + i18n: { language: 'zh-CN' }, }), })); @@ -87,6 +93,7 @@ vi.mock('@/api/client', () => ({ beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn(), @@ -304,6 +311,66 @@ describe('SessionChat standalone thinking indicator', () => { }); }); +describe('SessionChat agent mentions', () => { + const mentionAgents = [ + { + name: 'rex', + description: 'Main orchestrator', + descriptionCn: '主编排 Agent', + mode: 'primary', + permission: [], + options: {}, + skills: [], + tools: [], + }, + { + name: 'explore', + description: 'Explore the codebase', + descriptionCn: '探索代码库', + mode: 'subagent', + native: true, + permission: [], + options: {}, + skills: [], + tools: [], + }, + ]; + + it('shows matching agents when typing @', async () => { + const user = userEvent.setup(); + render(React.createElement(SessionChat, { + sessionId: 'sess-1', + mentionAgents, + })); + + await user.type(screen.getByPlaceholderText('请输入消息'), '@ex'); + + expect(screen.getByText('@explore')).toBeInTheDocument(); + expect(screen.getByText('探索代码库')).toBeInTheDocument(); + }); + + it('routes one message to the mentioned agent without changing the default agent', async () => { + const user = userEvent.setup(); + render(React.createElement(SessionChat, { + sessionId: 'sess-1', + agentName: 'rex', + mentionAgents, + })); + + await user.type(screen.getByPlaceholderText('请输入消息'), '@explore summarize this file{enter}'); + + await waitFor(() => { + expect(clientPostMock).toHaveBeenCalledWith( + '/api/session/sess-1/prompt_async', + expect.objectContaining({ + agent: 'explore', + parts: expect.any(Array), + }), + ); + }); + }); +}); + describe('truncateToolDisplayText', () => { it('returns short text unchanged', () => { expect(truncateToolDisplayText('bash')).toBe('bash'); diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index 0a183f60d..df4526423 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -17,7 +17,7 @@ */ import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'; -import { Send, Loader2, ChevronDown, Square, Copy, User, FileText, AlertCircle, X, RefreshCw, Pencil, Save, ImageIcon, Paperclip, ArrowUp, Clock, CheckCircle2, XCircle, Brain } from 'lucide-react'; +import { Send, Loader2, ChevronDown, Square, Copy, User, FileText, AlertCircle, X, RefreshCw, Pencil, Save, ImageIcon, Paperclip, ArrowUp, Clock, CheckCircle2, XCircle, Brain, Bot } from 'lucide-react'; import { StreamingMarkdown } from './StreamingMarkdown'; import { useTranslation } from 'react-i18next'; import LoadingSpinner from './LoadingSpinner'; @@ -32,9 +32,11 @@ import { usePendingQuestions, type PendingQuestion } from '@/hooks/usePendingQue import { sessionApi } from '@/api/session'; import client, { getApiBase } from '@/api/client'; import { commandAPI, type Command } from '@/api/skill'; +import type { Agent } from '@/api/agent'; import { useToast } from './Toast'; import { workspaceAPI } from '@/api/workspace'; import { formatSmartTime } from '@/utils/time'; +import { getAgentDisplayDescription } from '@/utils/agentDisplay'; import { FILE_INPUT_ACCEPT_IMAGES, batchCompressOptions, @@ -105,6 +107,8 @@ export interface SessionChatProps { onInitialMessageConsumed?: () => void; /** Agent name to include in prompt_async requests */ agentName?: string; + /** Agents available for one-turn @mention routing. */ + mentionAgents?: Agent[]; /** Display configuration (compact, showActions, showTimestamp) */ display?: SessionChatDisplay; /** Custom welcome content when no messages. Can be a render prop receiving setInput. */ @@ -130,7 +134,7 @@ export interface SessionChatProps { * session id) directly without an empty ``async (..) => { await ... }`` * shim. */ - onCreateAndSend?: (text: string, imageParts?: ImagePartData[]) => Promise | unknown; + onCreateAndSend?: (text: string, imageParts?: ImagePartData[], agentOverride?: string) => Promise | unknown; /** Called when the user sends "/new" to create a new session */ onCreateNewSession?: () => Promise | void; /** @@ -385,6 +389,35 @@ function isAllowedUploadFile(file: File): boolean { return ALLOWED_UPLOAD_EXTENSIONS.has(getFileExtension(file.name)); } +function formatAgentName(name: string): string { + return name ? name.charAt(0).toUpperCase() + name.slice(1) : name; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function findMentionTrigger(text: string, cursor: number): { start: number; end: number; query: string } | null { + const beforeCursor = text.slice(0, cursor); + const match = beforeCursor.match(/(^|\s)@([^\s@]*)$/); + if (!match) return null; + const query = match[2] ?? ''; + return { + start: beforeCursor.length - query.length - 1, + end: cursor, + query, + }; +} + +function resolveMentionAgentName(text: string, agents: Agent[]): string | null { + const sorted = [...agents].sort((a, b) => b.name.length - a.name.length); + for (const agent of sorted) { + const pattern = new RegExp(`(^|\\s)@${escapeRegExp(agent.name)}(?=$|\\s|[,.!?;:,。!?;:])`, 'i'); + if (pattern.test(text)) return agent.name; + } + return null; +} + function isUploadedDocumentAttachment(null); + const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); + const [pendingAgentName, setPendingAgentName] = useState(agentName || 'rex'); const successfulDocAttachments = useMemo( () => attachments.filter((a) => a.status === 'success' && a.workspacePath && !a.isImage), [attachments], @@ -571,6 +609,12 @@ export default function SessionChat({ const hasUploadingFiles = attachments.some((attachment) => attachment.status === 'uploading'); const canSend = !sending && !isStreaming && !hasUploadingFiles && (!!input.trim() || successfulDocAttachments.length > 0 || successfulImageAttachments.length > 0); + const filteredMentionAgents = useMemo(() => { + const q = mentionQuery.trim().toLowerCase(); + return mentionAgents + .filter((agent) => !q || agent.name.toLowerCase().startsWith(q)) + .slice(0, 12); + }, [mentionAgents, mentionQuery]); const scrollToBottom = useCallback(() => { if (!isAtBottomRef.current) return; @@ -785,6 +829,12 @@ export default function SessionChat({ }, [compact]); useEffect(() => { autoResize(); }, [input, autoResize]); + useEffect(() => { + if (!sending && !isStreaming) { + setPendingAgentName(agentName || 'rex'); + } + }, [agentName, sending, isStreaming]); + // Reset state on session change useEffect(() => { setIsStreaming(false); @@ -793,6 +843,10 @@ export default function SessionChat({ setIsCompacting(false); setCompactingMessage(''); setCompactionStages([]); + setMentionRange(null); + setMentionQuery(''); + setSelectedMentionIndex(0); + setPendingAgentName(agentName || 'rex'); abortingRef.current = false; abortedMessageIdRef.current = null; statusCheckedRef.current = null; @@ -1171,14 +1225,16 @@ export default function SessionChat({ }; /** Core send logic */ - const sendText = async (text: string, imageParts: ImagePartData[] = []) => { + const sendText = async (text: string, imageParts: ImagePartData[] = [], agentOverride?: string) => { if (!sessionId) return; + const effectiveAgent = agentOverride || agentName; // Clear abort state immediately so SSE events for the new stream are not suppressed abortingRef.current = false; // Force scroll to bottom when user sends a new message isAtBottomRef.current = true; setSending(true); setIsStreaming(true); + setPendingAgentName(effectiveAgent || 'rex'); const tempId = `temp-${Date.now()}`; const tempParts: MessagePart[] = []; @@ -1193,13 +1249,14 @@ export default function SessionChat({ role: 'user', parts: tempParts.length > 0 ? tempParts : [{ id: `${tempId}-part`, type: 'text', text }], timestamp: Date.now(), + agent: effectiveAgent, } as Message); try { const payload: Record = { parts: buildPromptParts(text, imageParts), }; - if (agentName) payload.agent = agentName; + if (effectiveAgent) payload.agent = effectiveAgent; await client.post(`/api/session/${sessionId}/prompt_async`, payload); } catch (err: unknown) { @@ -1222,12 +1279,14 @@ export default function SessionChat({ const docAttachmentsToSend = [...successfulDocAttachments]; const imageAttachmentsToSend = [...successfulImageAttachments]; const text = buildMessageText(rawText, docAttachmentsToSend); + const mentionedAgent = resolveMentionAgentName(rawText, mentionAgents); // Need either text content or image attachments if (!text && imageAttachmentsToSend.length === 0) return; setInput(''); setShowCommandDropdown(false); + setMentionRange(null); const imageParts: ImagePartData[] = imageAttachmentsToSend.map((a) => ({ url: a.dataUrl!, @@ -1264,7 +1323,8 @@ export default function SessionChat({ if (onCreateAndSend) { setSending(true); try { - await onCreateAndSend(text, imageParts); + setPendingAgentName(mentionedAgent || 'rex'); + await onCreateAndSend(text, imageParts, mentionedAgent || undefined); setAttachments([]); } catch { // Restore both the text and the attachment list so the user can @@ -1280,7 +1340,7 @@ export default function SessionChat({ } try { - await sendText(text, imageParts); + await sendText(text, imageParts, mentionedAgent || undefined); setAttachments([]); } catch { setInput(rawText); @@ -1300,7 +1360,57 @@ export default function SessionChat({ onInitialMessageConsumed?.(); }, [initialMessage, sessionId]); + const insertMention = useCallback((name: string) => { + const currentValue = textareaRef.current?.value ?? input; + const cursorPos = textareaRef.current?.selectionStart ?? currentValue.length; + const currentRange = findMentionTrigger(currentValue, cursorPos) ?? mentionRange; + if (!currentRange) return; + const next = `${currentValue.slice(0, currentRange.start)}@${name} ${currentValue.slice(currentRange.end)}`; + const cursor = currentRange.start + name.length + 2; + setInput(next); + setMentionRange(null); + setMentionQuery(''); + setSelectedMentionIndex(0); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + textareaRef.current?.setSelectionRange(cursor, cursor); + }); + }, [input, mentionRange]); + const handleKeyDown = (e: React.KeyboardEvent) => { + const currentValue = e.currentTarget instanceof HTMLTextAreaElement ? e.currentTarget.value : input; + const activeMention = mentionRange + ? findMentionTrigger(currentValue, textareaRef.current?.selectionStart ?? currentValue.length) + : null; + if (mentionRange && !activeMention) { + setMentionRange(null); + } + if (activeMention && filteredMentionAgents.length > 0) { + if (e.key === 'Escape') { + e.preventDefault(); + setMentionRange(null); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedMentionIndex((i) => (i - 1 + filteredMentionAgents.length) % filteredMentionAgents.length); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedMentionIndex((i) => (i + 1) % filteredMentionAgents.length); + return; + } + if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current)) { + e.preventDefault(); + const chosen = filteredMentionAgents[selectedMentionIndex] ?? filteredMentionAgents[0]; + if (chosen) { + insertMention(chosen.name); + } + return; + } + } + if (showCommandDropdown) { const filtered = commands.filter( (cmd) => !cmd.hidden && (commandQuery === '' || cmd.name.toLowerCase().startsWith(commandQuery.toLowerCase())) @@ -1654,11 +1764,11 @@ export default function SessionChat({ compact ? 'w-7 h-7 text-xs' : 'w-8 h-8 text-sm' }`} > - R + {formatAgentName(pendingAgentName).charAt(0).toUpperCase()}
- Rex + {formatAgentName(pendingAgentName)}
@@ -1712,11 +1822,11 @@ export default function SessionChat({ compact ? 'w-7 h-7 text-xs' : 'w-8 h-8 text-sm' }`} > - R + {formatAgentName(pendingAgentName).charAt(0).toUpperCase()}
- Rex + {formatAgentName(pendingAgentName)}
@@ -1785,6 +1895,13 @@ export default function SessionChat({ textareaRef.current?.focus(); }} /> + 0} + agents={filteredMentionAgents} + selectedIndex={selectedMentionIndex} + displayLang={i18n.language} + onSelect={(agent) => insertMention(agent.name)} + />
{ const val = e.target.value; setInput(val); + const cursor = e.target.selectionStart ?? val.length; + const mention = mentionAgents.length > 0 ? findMentionTrigger(val, cursor) : null; const trimmed = val.trimStart(); - if (trimmed.startsWith('/') && !trimmed.includes(' ') && successfulAttachments.length === 0) { + if (mention && !trimmed.startsWith('/')) { + setMentionRange({ start: mention.start, end: mention.end }); + setMentionQuery(mention.query); + setSelectedMentionIndex(0); + setShowCommandDropdown(false); + } else if (trimmed.startsWith('/') && !trimmed.includes(' ') && successfulAttachments.length === 0) { void loadCommandsIfNeeded(); const q = trimmed.slice(1); setCommandQuery(q); setSelectedCommandIndex(0); setShowCommandDropdown(true); + setMentionRange(null); } else { setShowCommandDropdown(false); + setMentionRange(null); } }} - onBlur={() => { setTimeout(() => setShowCommandDropdown(false), 100); }} + onBlur={() => { setTimeout(() => { setShowCommandDropdown(false); setMentionRange(null); }, 100); }} onCompositionStart={() => { isComposingRef.current = true; }} onCompositionEnd={() => { isComposingRef.current = false; }} onPaste={handleComposerPaste} @@ -2019,6 +2145,65 @@ export default function SessionChat({ ); } +function AgentMentionDropdown({ + visible, + agents, + selectedIndex, + displayLang, + onSelect, +}: { + visible: boolean; + agents: Agent[]; + selectedIndex: number; + displayLang: string; + onSelect: (agent: Agent) => void; +}) { + const { t } = useTranslation('session'); + const listRef = useRef(null); + + useEffect(() => { + const item = listRef.current?.children[selectedIndex] as HTMLElement | undefined; + item?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + if (!visible) return null; + + return ( +
e.preventDefault()} + > +
+ {t('chat.mention.title')} +
+
+ {agents.map((agent, idx) => { + const desc = getAgentDisplayDescription(agent, displayLang) || t('smartAssistant'); + return ( + + ); + })} +
+
+ ↑↓ {t('chat.mention.navigate')} + Enter/Tab {t('chat.mention.select')} +
+
+ ); +} + // ============================================================================ // ChatMessageBubble // ============================================================================ diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index e80dcbe14..0a8feac42 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -33,6 +33,20 @@ "loading": "Loading...", "noAgents": "No agents available", "smartAssistant": "AI Smart Assistant", + "agentPicker": { + "title": "Choose Chat Agent", + "hint": "Use this agent by default; type @Agent to route one message", + "filter": { + "all": "All", + "builtin": "Built-in", + "custom": "Custom" + }, + "badge": { + "primary": "Primary", + "builtin": "Built-in", + "custom": "Custom" + } + }, "welcome": { "title": "Start a new conversation", "description": "AI-native security operations assistant — threat analysis, incident response, and security orchestration", @@ -81,6 +95,11 @@ "summarizeDone": "Summary written ({{chars}} chars)", "complete": "Compaction complete" }, + "mention": { + "title": "Choose Agent", + "navigate": "Navigate", + "select": "Select" + }, "removeNodeRef": "Remove node reference", "errors": { "saveFailed": "Failed to save message", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 1716c7585..2b5e27ecc 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -33,6 +33,20 @@ "loading": "加载中...", "noAgents": "暂无可用的Agent", "smartAssistant": "智能AI助手", + "agentPicker": { + "title": "选择对话 Agent", + "hint": "默认使用此 Agent;输入 @Agent 可临时切换单条消息", + "filter": { + "all": "全部", + "builtin": "内置", + "custom": "自定义" + }, + "badge": { + "primary": "主 Agent", + "builtin": "内置", + "custom": "自定义" + } + }, "welcome": { "title": "开始新的对话", "description": "AI 原生的安全运营助手,帮您进行威胁分析、应急响应、安全编排等任务", @@ -81,6 +95,11 @@ "summarizeDone": "生成摘要 {{chars}} 字", "complete": "压缩完成" }, + "mention": { + "title": "选择 Agent", + "navigate": "导航", + "select": "选择" + }, "removeNodeRef": "移除节点引用", "errors": { "saveFailed": "保存消息失败", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index dcfc6a90b..6e5e7996a 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -68,8 +68,24 @@ vi.mock('@/components/common/LoadingSpinner', () => ({ vi.mock('@/components/common/SessionChat', () => ({ __esModule: true, - default: ({ sessionId }: { sessionId?: string | null }) => ( -
{sessionId ?? 'no-session'}
+ default: ({ + sessionId, + mentionAgents, + toolbarSlot, + onCreateAndSend, + }: { + sessionId?: string | null; + mentionAgents?: Array<{ name: string }>; + toolbarSlot?: React.ReactNode; + onCreateAndSend?: (text: string, imageParts?: unknown[], agentOverride?: string) => Promise | unknown; + }) => ( +
a.name).join(',')}> + {sessionId ?? 'no-session'} + {toolbarSlot} + +
), })); @@ -329,4 +345,141 @@ describe('SessionPage session actions menu', () => { expect(screen.getByTestId('session-chat')).toHaveTextContent('session-2'); }); }); + + it('lists the same visible agents as the Agent page selector logic', async () => { + const user = userEvent.setup(); + useAgents.mockReturnValue({ + agents: [ + { + name: 'rex', + description: 'Rex', + mode: 'primary', + permission: [], + options: {}, + skills: [], + tools: [], + }, + { + name: 'explore', + description: 'Explore', + mode: 'subagent', + native: true, + permission: [], + options: {}, + skills: [], + tools: [], + }, + { + name: 'hidden-system', + description: 'System', + mode: 'subagent', + tags: ['system'], + permission: [], + options: {}, + skills: [], + tools: [], + }, + ], + loading: false, + error: null, + refetch: vi.fn(), + }); + + renderSessionPage(); + + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-mention-agents', 'rex,explore'); + + await user.click(screen.getByRole('button', { name: /Rex/i })); + + expect(screen.getByRole('button', { name: /Explore/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /hidden-system/i })).not.toBeInTheDocument(); + }); + + it('resets the chat agent to Rex when creating a new session', async () => { + const user = userEvent.setup(); + useAgents.mockReturnValue({ + agents: [ + { + name: 'rex', + description: 'Rex', + mode: 'primary', + permission: [], + options: {}, + skills: [], + tools: [], + }, + { + name: 'explore', + description: 'Explore', + mode: 'subagent', + native: true, + permission: [], + options: {}, + skills: [], + tools: [], + }, + ], + loading: false, + error: null, + refetch: vi.fn(), + }); + + renderSessionPage(); + + await user.click(screen.getByRole('button', { name: /Rex/i })); + await user.click(screen.getByRole('button', { name: /Explore/i })); + expect(screen.getByRole('button', { name: /Explore/i })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'newSession' })); + + await waitFor(() => { + expect(addSession).toHaveBeenCalledWith(secondSession); + }); + expect(screen.getByRole('button', { name: /Rex/i })).toBeInTheDocument(); + }); + + it('uses Rex for the first message when an empty session is created by sending', async () => { + const user = userEvent.setup(); + useAgents.mockReturnValue({ + agents: [ + { + name: 'rex', + description: 'Rex', + mode: 'primary', + permission: [], + options: {}, + skills: [], + tools: [], + }, + { + name: 'explore', + description: 'Explore', + mode: 'subagent', + native: true, + permission: [], + options: {}, + skills: [], + tools: [], + }, + ], + loading: false, + error: null, + refetch: vi.fn(), + }); + + renderSessionPage(); + + await user.click(screen.getByRole('button', { name: /Rex/i })); + await user.click(screen.getByRole('button', { name: /Explore/i })); + expect(screen.getByRole('button', { name: /Explore/i })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'mock-create-and-send' })); + + await waitFor(() => { + expect(client.post).toHaveBeenCalledWith( + '/api/session/session-2/prompt_async', + expect.objectContaining({ agent: 'rex' }), + ); + }); + expect(screen.getByRole('button', { name: /Rex/i })).toBeInTheDocument(); + }); }); diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index 9390aa677..8ab60ae61 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -13,6 +13,7 @@ import LoadingSpinner from '@/components/common/LoadingSpinner'; import { useToast } from '@/components/common/Toast'; import SessionChat, { type SSEChatEvent, type SSEConnectionStatus } from '@/components/common/SessionChat'; import { sessionApi } from '@/api/session'; +import type { Agent } from '@/api/agent'; import { useSessions } from '@/hooks/useSessions'; import { useAgents } from '@/hooks/useAgents'; import client from '@/api/client'; @@ -31,6 +32,19 @@ function sanitizeSessionExportName(value: string) { } const LAST_SELECTED_SESSION_STORAGE_KEY = 'flocks:last-selected-session'; +type AgentSourceFilter = 'all' | 'builtin' | 'custom'; + +function formatAgentName(name: string): string { + return name ? name.charAt(0).toUpperCase() + name.slice(1) : name; +} + +function getAgentSecondaryDescription(agent: Agent, language: string): string { + const isZh = language.toLowerCase().replace('_', '-').startsWith('zh'); + const primary = (isZh ? agent.descriptionCn : agent.description)?.trim(); + const secondary = (isZh ? agent.description : agent.descriptionCn)?.trim(); + if (primary && secondary && primary !== secondary) return secondary; + return ''; +} function readLastSelectedSessionId(): string | null { try { @@ -73,13 +87,31 @@ export default function SessionPage() { const [downloadingSessionId, setDownloadingSessionId] = useState(null); const supportsVision = useDefaultModelVision(); const [searchQuery, setSearchQuery] = useState(''); + const [agentSourceFilter, setAgentSourceFilter] = useState('all'); const renameInputRef = useRef(null); const renameSubmitInFlightRef = useRef(false); const toast = useToast(); const { sessions, loading: loadingSessions, refetch: refetchSessions, updateSessionTitle, removeSession, removeSessions, addSession } = useSessions(); const { agents, loading: loadingAgents } = useAgents(); - const rexAgents = useMemo(() => agents.filter(a => a.name.toLowerCase() === 'rex'), [agents]); + const primaryAgents = useMemo(() => agents.filter((a) => a.mode === 'primary'), [agents]); + const subAgents = useMemo( + () => agents.filter((a) => a.mode !== 'primary' && !(a.tags ?? []).includes('system')), + [agents], + ); + const chatAgents = useMemo(() => [...primaryAgents, ...subAgents], [primaryAgents, subAgents]); + const filteredChatAgents = useMemo( + () => chatAgents.filter((agent) => { + if (agentSourceFilter === 'builtin') return agent.native; + if (agentSourceFilter === 'custom') return !agent.native; + return true; + }), + [chatAgents, agentSourceFilter], + ); + const selectedAgentInfo = useMemo( + () => chatAgents.find((agent) => agent.name === selectedAgent), + [chatAgents, selectedAgent], + ); const selectedSession = useMemo( () => sessions.find(s => s.id === selectedSessionId) ?? null, [sessions, selectedSessionId], @@ -221,6 +253,7 @@ export default function SessionPage() { try { const response = await client.post('/api/session', { title: 'New Session' }); addSession(response.data); + setSelectedAgent('rex'); setSelectedSessionId(response.data.id); } catch (err: any) { toast.error(t('createFailed'), err.message); @@ -260,25 +293,28 @@ export default function SessionPage() { const handleCreateAndSend = useCallback(async ( text: string, imageParts?: ImagePartData[], + agentOverride?: string, ) => { try { const response = await client.post('/api/session', { title: 'New Session' }); const newSessionId = response.data.id; addSession(response.data); + setSelectedAgent('rex'); setSelectedSessionId(newSessionId); const payload: Record = { parts: buildPromptParts(text, imageParts), }; - if (selectedAgent) payload.agent = selectedAgent; + const effectiveAgent = agentOverride || 'rex'; + if (effectiveAgent) payload.agent = effectiveAgent; client.post(`/api/session/${newSessionId}/prompt_async`, payload).catch((err: any) => { toast.error(t('chat.sendFailed', 'Send failed'), err.message); }); } catch (err: any) { toast.error(t('createFailed'), err.message); } - }, [addSession, selectedAgent, toast, t]); + }, [addSession, toast, t]); const handleDeleteSession = useCallback(async (sessionId: string) => { const target = sessions.find((s) => s.id === sessionId); @@ -673,6 +709,7 @@ export default function SessionPage() { live={Boolean(selectedSessionId)} display={{ compact: false, showActions: true, showTimestamp: true }} agentName={selectedAgent} + mentionAgents={chatAgents} className="flex-1 min-h-0" initialMessage={pendingInitialMessage} onInitialMessageConsumed={() => setPendingInitialMessage(null)} @@ -690,43 +727,81 @@ export default function SessionPage() {
{showAgentOptions && ( -
-
+
+
+
+
{t('agentPicker.title')}
+
{t('agentPicker.hint')}
+
+
+ {(['all', 'builtin', 'custom'] as AgentSourceFilter[]).map((filter) => ( + + ))} +
+
+
{loadingAgents ? (
{t('loading')}
- ) : rexAgents.length > 0 ? ( - rexAgents.map((agent) => ( + ) : filteredChatAgents.length > 0 ? ( + filteredChatAgents.map((agent) => { + const primaryDesc = getAgentDisplayDescription(agent, i18n.language) || t('smartAssistant'); + const secondaryDesc = getAgentSecondaryDescription(agent, i18n.language); + return ( - )) + ); + }) ) : (
{t('noAgents')}
)}