Skip to content

Commit 7b0b6ca

Browse files
committed
✨ Support Agent switching at conversation start.
1 parent 53a5fab commit 7b0b6ca

File tree

1 file changed

+294
-0
lines changed

1 file changed

+294
-0
lines changed
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"use client"
2+
3+
import React, { useState, useEffect, useRef } from 'react'
4+
import { createPortal } from 'react-dom'
5+
import { ChevronDown } from 'lucide-react'
6+
import { Button } from '@/components/ui/button'
7+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
8+
import { fetchAllAgents } from '@/services/agentConfigService'
9+
import { useTranslation } from 'react-i18next'
10+
11+
interface Agent {
12+
agent_id: number
13+
name: string
14+
description: string
15+
is_available: boolean
16+
}
17+
18+
interface AgentSelectorProps {
19+
selectedAgentId: number | null
20+
onAgentSelect: (agentId: number | null) => void
21+
disabled?: boolean
22+
isInitialMode?: boolean
23+
}
24+
25+
export function AgentSelector({ selectedAgentId, onAgentSelect, disabled = false, isInitialMode = false }: AgentSelectorProps) {
26+
const [agents, setAgents] = useState<Agent[]>([])
27+
const [isOpen, setIsOpen] = useState(false)
28+
const [isLoading, setIsLoading] = useState(false)
29+
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, direction: 'down' })
30+
const { t } = useTranslation('common')
31+
const buttonRef = useRef<HTMLDivElement>(null)
32+
33+
const selectedAgent = agents.find(agent => agent.agent_id === selectedAgentId)
34+
35+
useEffect(() => {
36+
loadAgents()
37+
}, [])
38+
39+
// 计算下拉框位置
40+
useEffect(() => {
41+
if (isOpen && buttonRef.current) {
42+
const buttonRect = buttonRef.current.getBoundingClientRect()
43+
const viewportHeight = window.innerHeight
44+
const dropdownHeight = 240 // 估算下拉框高度 (max-h-60)
45+
46+
// 检查是否有足够空间向下显示
47+
const hasSpaceBelow = buttonRect.bottom + dropdownHeight + 10 < viewportHeight
48+
// 检查是否有足够空间向上显示
49+
const hasSpaceAbove = buttonRect.top - dropdownHeight - 10 > 0
50+
51+
let direction = 'down'
52+
let top = buttonRect.bottom + 4
53+
54+
// 决定方向:优先使用建议方向,但如果空间不足则调整
55+
if (isInitialMode) {
56+
// 初始模式优先向下
57+
if (!hasSpaceBelow && hasSpaceAbove) {
58+
direction = 'up'
59+
top = buttonRect.top - 4
60+
}
61+
} else {
62+
// 非初始模式优先向上
63+
direction = 'up'
64+
top = buttonRect.top - 4
65+
if (!hasSpaceAbove && hasSpaceBelow) {
66+
direction = 'down'
67+
top = buttonRect.bottom + 4
68+
}
69+
}
70+
71+
setDropdownPosition({
72+
top,
73+
left: buttonRect.left,
74+
direction
75+
})
76+
}
77+
}, [isOpen, isInitialMode])
78+
79+
// 监听窗口滚动和尺寸变化,关闭下拉框
80+
useEffect(() => {
81+
if (!isOpen) return
82+
83+
const handleScroll = (e: Event) => {
84+
// 如果滚动发生在下拉框内部,不关闭下拉框
85+
const target = e.target as Node
86+
const dropdownElement = document.querySelector('.agent-selector-dropdown')
87+
if (dropdownElement && (dropdownElement === target || dropdownElement.contains(target))) {
88+
return
89+
}
90+
91+
// 如果是页面滚动或其他容器的滚动,则关闭下拉框
92+
setIsOpen(false)
93+
}
94+
95+
const handleResize = () => {
96+
setIsOpen(false)
97+
}
98+
99+
// 使用事件捕获阶段
100+
window.addEventListener('scroll', handleScroll, true)
101+
window.addEventListener('resize', handleResize)
102+
103+
return () => {
104+
window.removeEventListener('scroll', handleScroll, true)
105+
window.removeEventListener('resize', handleResize)
106+
}
107+
}, [isOpen])
108+
109+
const loadAgents = async () => {
110+
setIsLoading(true)
111+
try {
112+
const result = await fetchAllAgents()
113+
if (result.success) {
114+
setAgents(result.data)
115+
}
116+
} catch (error) {
117+
console.error('加载Agent列表失败:', error)
118+
} finally {
119+
setIsLoading(false)
120+
}
121+
}
122+
123+
const handleAgentSelect = (agentId: number | null) => {
124+
// 只有可用的Agent才能被选择
125+
if (agentId !== null) {
126+
const agent = agents.find(a => a.agent_id === agentId)
127+
if (agent && !agent.is_available) {
128+
return // 不可用的Agent不能被选择
129+
}
130+
}
131+
onAgentSelect(agentId)
132+
setIsOpen(false)
133+
}
134+
135+
// 显示所有agents,包括不可用的
136+
const allAgents = agents
137+
138+
return (
139+
<div className="relative">
140+
<div
141+
ref={buttonRef}
142+
className={`
143+
relative h-8 min-w-[120px] max-w-[180px] px-2
144+
rounded-md border border-slate-200
145+
bg-white hover:bg-slate-50
146+
flex items-center justify-between
147+
cursor-pointer select-none
148+
transition-colors duration-150
149+
${disabled || isLoading ? 'opacity-50 cursor-not-allowed' : ''}
150+
${isOpen ? 'border-blue-400 ring-2 ring-blue-100' : 'hover:border-slate-300'}
151+
`}
152+
onClick={() => !disabled && !isLoading && setIsOpen(!isOpen)}
153+
>
154+
<div className="flex items-center gap-2 truncate">
155+
{selectedAgent && (
156+
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
157+
)}
158+
<span className={`truncate text-sm ${selectedAgent ? 'font-medium text-slate-700' : 'text-slate-500'}`}>
159+
{isLoading
160+
? (
161+
<div className="flex items-center gap-2">
162+
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
163+
<span>{t('agentSelector.loading')}</span>
164+
</div>
165+
)
166+
: selectedAgent
167+
? selectedAgent.name
168+
: t('agentSelector.selectAgent')
169+
}
170+
</span>
171+
</div>
172+
<ChevronDown
173+
className={`h-4 w-4 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
174+
/>
175+
</div>
176+
177+
{/* Portal渲染下拉框到body,避免被父容器遮挡 */}
178+
{isOpen && typeof window !== 'undefined' && createPortal(
179+
<>
180+
{/* 覆盖层 */}
181+
<div
182+
className="fixed inset-0 z-[9998]"
183+
onClick={() => setIsOpen(false)}
184+
onWheel={(e) => {
185+
// 如果滚动发生在下拉框内部,不关闭下拉框
186+
const target = e.target as Node
187+
const dropdownElement = document.querySelector('.agent-selector-dropdown')
188+
if (dropdownElement && (dropdownElement === target || dropdownElement.contains(target))) {
189+
return
190+
}
191+
setIsOpen(false)
192+
}}
193+
/>
194+
195+
{/* 下拉框 */}
196+
<div
197+
className="agent-selector-dropdown fixed w-64 bg-white border border-slate-200 rounded-md shadow-lg z-[9999] max-h-60 overflow-y-auto"
198+
style={{
199+
top: dropdownPosition.direction === 'up'
200+
? `${dropdownPosition.top}px`
201+
: `${dropdownPosition.top}px`,
202+
left: `${dropdownPosition.left}px`,
203+
transform: dropdownPosition.direction === 'up' ? 'translateY(-100%)' : 'none'
204+
}}
205+
onWheel={(e) => {
206+
// 阻止滚动事件冒泡,但允许正常滚动
207+
e.stopPropagation()
208+
}}
209+
>
210+
<div className="py-1">
211+
{allAgents.length === 0 ? (
212+
<div className="px-3 py-2.5 text-sm text-slate-500 text-center">
213+
{isLoading ? (
214+
<div className="flex items-center justify-center gap-2">
215+
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
216+
<span>{t('agentSelector.loading')}</span>
217+
</div>
218+
) : (
219+
t('agentSelector.noAvailableAgents')
220+
)}
221+
</div>
222+
) : (
223+
allAgents.map((agent, idx) => (
224+
<TooltipProvider key={agent.agent_id}>
225+
<Tooltip>
226+
<TooltipTrigger asChild>
227+
<div
228+
className={`
229+
flex items-center px-3.5 py-2.5 text-sm
230+
transition-all duration-150 ease-in-out
231+
${agent.is_available
232+
? `hover:bg-slate-50 cursor-pointer ${
233+
selectedAgentId === agent.agent_id
234+
? 'bg-blue-50/70 text-blue-600 hover:bg-blue-50/70 font-medium'
235+
: ''
236+
}`
237+
: 'cursor-not-allowed bg-slate-50/50'
238+
}
239+
${selectedAgentId === agent.agent_id ? 'shadow-[inset_2px_0_0_0] shadow-blue-500' : ''}
240+
${idx !== 0 ? 'border-t border-slate-100' : ''}
241+
`}
242+
onClick={() => agent.is_available && handleAgentSelect(agent.agent_id)}
243+
>
244+
<div className="flex-1 truncate">
245+
<div className={`${
246+
agent.is_available
247+
? selectedAgentId === agent.agent_id
248+
? 'text-blue-600'
249+
: 'text-slate-700 hover:text-slate-900'
250+
: 'text-slate-400'
251+
}`}>
252+
{agent.name}
253+
</div>
254+
</div>
255+
</div>
256+
</TooltipTrigger>
257+
<TooltipContent
258+
side="right"
259+
className="max-w-lg bg-white rounded-lg shadow-lg border border-slate-200 p-0 overflow-hidden"
260+
sideOffset={5}
261+
>
262+
<div className="relative">
263+
{/* 顶部状态条 */}
264+
<div className={`h-1 w-full ${agent.is_available ? 'bg-green-500' : 'bg-red-400'}`} />
265+
266+
{/* 主要内容区 */}
267+
<div className="p-4">
268+
{/* 描述文本 */}
269+
<div className="text-sm leading-relaxed text-slate-600 whitespace-normal break-words">
270+
{agent.description}
271+
</div>
272+
273+
{/* 不可用状态提示 */}
274+
{!agent.is_available && (
275+
<div className="mt-3 pt-3 border-t border-slate-200 flex items-start gap-2.5 text-sm text-red-500">
276+
<div className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0 animate-pulse mt-1" />
277+
{t('agentSelector.agentUnavailable')}
278+
</div>
279+
)}
280+
</div>
281+
</div>
282+
</TooltipContent>
283+
</Tooltip>
284+
</TooltipProvider>
285+
))
286+
)}
287+
</div>
288+
</div>
289+
</>,
290+
document.body
291+
)}
292+
</div>
293+
)
294+
}

0 commit comments

Comments
 (0)