feat: 对话框上方集成专家选择器,支持实时选择与系统提示词注入

- 新建 ExpertSelector 组件,加载时查询 session 选中状态,失败降级显示无专家

- 下拉面板含无专家/专家列表/管理专家入口,乐观更新+失败回滚保证一致性

- ChatContainer 在 MessageList 与 MessageInput 之间渲染 ExpertSelector

- MessageInput 接收 selectedExpert,选中时 placeholder 显示以专家身份对话

- App.tsx 传递 sessionId/onOpenSettings 给 ChatContainer,支持从对话框直达专家设置

- App.tsx 同时包含 subagent 导航 command 发送的连带修改
This commit is contained in:
oudecheng 2026-07-06 18:08:19 +08:00
parent 5651c4ae7f
commit 61c2fca2a7
4 changed files with 298 additions and 8 deletions

View File

@ -166,6 +166,7 @@ function App() {
})
const [configPageOpen, setConfigPageOpen] = useState(false)
const [configInitialTab, setConfigInitialTab] = useState<'providers' | 'experts'>('providers')
const handleSaveConnection = useCallback((host: string, port: number) => {
setGatewaySettings({ host, port })
@ -355,8 +356,18 @@ function App() {
)
const handleExitSubAgentView = useCallback(() => {
exitSubAgentView()
}, [exitSubAgentView])
const command = exitSubAgentView()
if (command) {
sendMessage({ type: 'command', payload: JSON.stringify(command) })
}
}, [exitSubAgentView, sendMessage])
const handleNavigateToSubAgentLevel = useCallback((index: number) => {
const command = navigateToSubAgentLevel(index)
if (command) {
sendMessage({ type: 'command', payload: JSON.stringify(command) })
}
}, [navigateToSubAgentLevel, sendMessage])
// 切换到定时任务 tab 时自动获取列表
useEffect(() => {
@ -567,7 +578,10 @@ function App() {
<Brain className="h-4 w-4" />
</button>
<button
onClick={() => setConfigPageOpen(true)}
onClick={() => {
setConfigInitialTab('providers')
setConfigPageOpen(true)
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all"
title="系统配置"
aria-label="System config"
@ -726,7 +740,7 @@ function App() {
</button>
{/* Breadcrumb: 主会话 */}
<button
onClick={() => navigateToSubAgentLevel(-1)}
onClick={() => handleNavigateToSubAgentLevel(-1)}
className="flex items-center gap-1 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors shrink-0"
title="返回主会话"
>
@ -762,7 +776,7 @@ function App() {
</div>
) : (
<button
onClick={() => navigateToSubAgentLevel(idx)}
onClick={() => handleNavigateToSubAgentLevel(idx)}
className="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors min-w-0"
>
<span className="truncate">{level.description}</span>
@ -793,6 +807,11 @@ function App() {
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
sessionId={sessionId}
onOpenSettings={() => {
setConfigInitialTab('experts')
setConfigPageOpen(true)
}}
/>
</div>
</div>
@ -881,7 +900,11 @@ function App() {
{/* 系统配置页面 */}
{configPageOpen && (
<ConfigPage onClose={() => setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} />
<ConfigPage
onClose={() => setConfigPageOpen(false)}
onSaveConnection={handleSaveConnection}
initialTab={configInitialTab}
/>
)}
</div>
)

View File

@ -1,5 +1,7 @@
import { useState } from 'react'
import { MessageList } from './MessageList'
import { MessageInput } from './MessageInput'
import { ExpertSelector } from './ExpertSelector'
import type { ChatMessage, Attachment } from '../../types/protocol'
interface ChatContainerProps {
@ -15,6 +17,10 @@ interface ChatContainerProps {
viewKey?: string
/** 高亮的消息 ID */
highlightedMessageId?: string | null
/** 当前 session ID用于专家选择 */
sessionId?: string | null
/** 打开设置页(用于专家管理入口) */
onOpenSettings?: () => void
}
export function ChatContainer({
@ -28,12 +34,21 @@ export function ChatContainer({
showThinking = true,
viewKey,
highlightedMessageId,
sessionId,
onOpenSettings,
}: ChatContainerProps) {
const [selectedExpert, setSelectedExpert] = useState<{ name: string; description: string } | null>(null)
return (
<div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
</div>
<ExpertSelector
sessionId={sessionId ?? null}
onManageExperts={onOpenSettings}
onSelectionChange={setSelectedExpert}
/>
<MessageInput
onSend={onSendMessage}
onStop={onStop}
@ -41,6 +56,7 @@ export function ChatContainer({
isLoading={isLoading}
isReadOnly={isReadOnly}
channelName={channelName}
selectedExpert={selectedExpert}
/>
</div>
)

View File

@ -0,0 +1,247 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react'
import { getSelectedExpert, selectExpert } from '../Settings/ConfigPage'
interface ExpertItem {
name: string
description: string
source: string
path?: string
body?: string
disabled_in_scopes: string[]
}
interface SelectedExpert {
name: string
description: string
}
interface ExpertSelectorProps {
sessionId: string | null
onManageExperts?: () => void
onSelectionChange?: (expert: SelectedExpert | null) => void
}
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }: ExpertSelectorProps) {
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null)
const [expertList, setExpertList] = useState<ExpertItem[]>([])
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [listLoading, setListLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const listFetchedRef = useRef(false)
// Load current selection whenever sessionId changes
useEffect(() => {
if (!sessionId) {
setSelectedExpert(null)
onSelectionChange?.(null)
return
}
let cancelled = false
setLoading(true)
setError(null)
getSelectedExpert(sessionId)
.then(data => {
if (cancelled) return
if (data?.expert) {
setSelectedExpert({ name: data.expert.name, description: data.expert.description })
onSelectionChange?.({ name: data.expert.name, description: data.expert.description })
} else {
setSelectedExpert(null)
onSelectionChange?.(null)
}
})
.catch(() => {
if (cancelled) return
// Silent fail: default to no expert
setSelectedExpert(null)
onSelectionChange?.(null)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
// Click outside to close dropdown
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const fetchExpertList = useCallback(async () => {
setListLoading(true)
try {
const resp = await fetch('/api/experts')
if (resp.ok) {
const data = await resp.json()
// Only show enabled experts (disabled_in_scopes.length === 0)
const enabled = (data.experts ?? []).filter(
(e: ExpertItem) => e.disabled_in_scopes.length === 0
)
setExpertList(enabled)
}
} catch { /* ignore */ }
finally { setListLoading(false) }
}, [])
const handleToggleOpen = () => {
const next = !open
setOpen(next)
if (next && !listFetchedRef.current) {
listFetchedRef.current = true
fetchExpertList()
}
}
const handleSelect = async (expert: SelectedExpert | null) => {
if (!sessionId) return
// Optimistic update
const prev = selectedExpert
setSelectedExpert(expert)
onSelectionChange?.(expert)
setOpen(false)
try {
const result = await selectExpert(sessionId, expert?.name ?? null)
if (!result.success) {
// Revert
setSelectedExpert(prev)
onSelectionChange?.(prev)
setError(result.error || '切换专家失败')
setTimeout(() => setError(null), 3000)
}
} catch {
setSelectedExpert(prev)
onSelectionChange?.(prev)
setError('网络错误,切换专家失败')
setTimeout(() => setError(null), 3000)
}
}
const handleManage = () => {
setOpen(false)
onManageExperts?.()
}
// If sessionId is null, render nothing
if (!sessionId) return null
return (
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0">
<div className="max-w-5xl mx-auto flex items-center gap-2">
<button
onClick={handleToggleOpen}
disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
) : (
<UserCheck
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
)}
{selectedExpert ? (
<span className="flex items-center gap-1 min-w-0">
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
{selectedExpert.name}
</span>
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
{selectedExpert.description}
</span>
</span>
) : (
<span className="text-[var(--text-muted)]"></span>
)}
<ChevronDown
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div>
{open && (
<div
className="absolute z-30 mt-1 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
style={{ left: 'max(1rem, calc(50% - 18rem))' }}
>
{listLoading && expertList.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> ...
</div>
) : (
<>
{/* 无专家 option */}
<button
onClick={() => handleSelect(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
<span className="text-[var(--text-primary)]"></span>
{!selectedExpert && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
)}
</button>
{expertList.length > 0 && (
<div className="border-t border-[var(--border-color)]">
{expertList.map(expert => {
const isSelected = selectedExpert?.name === expert.name
return (
<button
key={expert.name}
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-[var(--text-primary)] truncate">
{expert.name}
</span>
<span className="text-[9px] px-1 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider shrink-0">
{expert.source}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0" />
)}
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
{expert.description}
</p>
</div>
</button>
)
})}
</div>
)}
<div className="border-t border-[var(--border-color)]">
<button
onClick={handleManage}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Settings className="h-4 w-4" />
<span>...</span>
</button>
</div>
</>
)}
</div>
)}
</div>
)
}

View File

@ -12,6 +12,7 @@ interface MessageInputProps {
placeholder?: string
isReadOnly?: boolean
channelName?: string
selectedExpert?: { name: string; description: string } | null
}
interface FileAttachment {
@ -33,10 +34,13 @@ export function MessageInput({
onStop,
disabled = false,
isLoading = false,
placeholder = '输入消息...按 / 查看命令',
placeholder,
isReadOnly = false,
channelName,
selectedExpert,
}: MessageInputProps) {
const effectivePlaceholder = placeholder
?? (selectedExpert ? `${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令')
const [content, setContent] = useState('')
const [attachments, setAttachments] = useState<FileAttachment[]>([])
const [isDragging, setIsDragging] = useState(false)
@ -360,7 +364,7 @@ export function MessageInput({
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
placeholder={effectivePlaceholder}
disabled={disabled}
rows={1}
className="w-full resize-none rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-4 py-3 pr-12 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-cyan)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--focus-ring)] disabled:opacity-50 transition-all self-center scrollbar-hide"