feat: 添加swarms团队编排功能并优化agent委派系统

- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
This commit is contained in:
2026-04-14 14:34:23 +08:00
parent fee9007da6
commit cdfc222c9f
85 changed files with 5443 additions and 1392 deletions

View File

@ -14,7 +14,6 @@ import {
createSession,
deleteSession,
getSession,
getStatus,
listCommands,
listSessions,
sendMessage,
@ -22,29 +21,10 @@ import {
wsManager,
} from '@/lib/api';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
if (typeof window === 'undefined') {
task();
return () => {};
}
const idleWindow = window as Window &
typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (typeof idleWindow.requestIdleCallback === 'function') {
const id = idleWindow.requestIdleCallback(() => task(), { timeout });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = globalThis.setTimeout(task, 250);
return () => globalThis.clearTimeout(id);
}
import type { ChatMessage, FileAttachment, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
function messageFingerprint(msg: ChatMessage): string {
const attachmentKey = (msg.attachments ?? [])
@ -76,16 +56,12 @@ function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessage
return [...serverMessages, ...pendingUsers];
}
function isProcessEvent(data: WsEvent | Record<string, unknown>): data is ProcessWsEvent {
const type = typeof data.type === 'string' ? data.type : '';
return type.startsWith('process_') || type === 'process_cancel_ack';
}
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
return data.type === 'session_updated' && typeof data.session_id === 'string';
}
export default function ChatPage() {
const { locale } = useAppI18n();
const {
sessionId,
messages,
@ -100,13 +76,8 @@ export default function ChatPage() {
setMessages,
addMessage,
setIsLoading,
setSessions,
clearMessages,
setWsStatus,
setIsThinking,
setNanobotReady,
resetProcessState,
ingestProcessEvent,
setSelectedRunId,
} = useChatStore();
@ -124,9 +95,8 @@ export default function ChatPage() {
const commandsLoadedRef = useRef(false);
const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false);
const statusCheckCleanupRef = useRef<(() => void) | null>(null);
const statusCheckInFlightRef = useRef(false);
const shouldSnapToLatestRef = useRef(true);
const wsStatus = useChatStore((state) => state.wsStatus);
const filteredCommands = useMemo(() => {
if (!input.startsWith('/') || input.includes(' ')) return [];
@ -136,6 +106,28 @@ export default function ChatPage() {
);
}, [commands, input]);
const sessionProcessRuns = useMemo(
() => processRuns.filter((run) => run.session_id === sessionId),
[processRuns, sessionId]
);
const sessionRunIds = useMemo(
() => new Set(sessionProcessRuns.map((run) => run.run_id)),
[sessionProcessRuns]
);
const sessionProcessEvents = useMemo(
() => processEvents.filter((event) => sessionRunIds.has(event.run_id)),
[processEvents, sessionRunIds]
);
const sessionProcessArtifacts = useMemo(
() => processArtifacts.filter((artifact) => sessionRunIds.has(artifact.run_id)),
[processArtifacts, sessionRunIds]
);
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
const officeTasks = useMemo(
() => buildOfficeTaskList({
sessionId,
@ -143,8 +135,8 @@ export default function ChatPage() {
processRuns,
processEvents,
processArtifacts,
}),
[processArtifacts, processEvents, processRuns, sessionId, sessions]
}, locale),
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
);
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
@ -152,11 +144,11 @@ export default function ChatPage() {
const loadSessions = useCallback(async () => {
try {
const list = await listSessions();
setSessions(list);
useChatStore.getState().setSessions(list);
} catch {
// backend may be offline during first render
}
}, [setSessions]);
}, []);
const loadSessionMessages = useCallback(async (key: string) => {
const reqSeq = ++loadSessionReqSeq.current;
@ -170,6 +162,7 @@ export default function ChatPage() {
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages;
setMessages(nextMessages);
shouldSnapToLatestRef.current = true;
const last = nextMessages[nextMessages.length - 1];
if (last?.role === 'assistant') {
setIsThinking(false);
@ -192,23 +185,6 @@ export default function ChatPage() {
}
}, []);
const scheduleStatusCheck = useCallback(() => {
if (statusCheckInFlightRef.current) return;
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = scheduleWhenIdle(async () => {
statusCheckInFlightRef.current = true;
try {
await getStatus();
setNanobotReady(true);
} catch {
setNanobotReady(false);
} finally {
statusCheckInFlightRef.current = false;
}
});
}, [setNanobotReady]);
useEffect(() => {
if (input.startsWith('/') && !input.includes(' ')) {
void loadCommands();
@ -220,40 +196,29 @@ export default function ChatPage() {
setPickerIndex(0);
}, [filteredCommands]);
useEffect(() => {
loadSessions();
}, [loadSessions]);
useEffect(() => {
clearMessages();
setIsLoading(false);
setIsThinking(false);
resetProcessState();
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
wsManager.connect(wsSessionId);
loadSessionMessages(sessionId);
}, [clearMessages, loadSessionMessages, resetProcessState, sessionId, setIsLoading, setIsThinking]);
void loadSessionMessages(sessionId);
}, [clearMessages, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
useEffect(() => {
const unsubStatus = wsManager.onStatusChange(async (status) => {
setWsStatus(status);
if (status === 'connected') {
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
refreshSessionOnReconnectRef.current = false;
void loadSessionMessages(useChatStore.getState().sessionId);
}
hasConnectedRef.current = true;
scheduleStatusCheck();
} else {
if (status === 'disconnected' && hasConnectedRef.current) {
refreshSessionOnReconnectRef.current = true;
}
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
setNanobotReady(null);
if (wsStatus === 'connected') {
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
refreshSessionOnReconnectRef.current = false;
void loadSessionMessages(useChatStore.getState().sessionId);
}
});
hasConnectedRef.current = true;
return;
}
if (wsStatus === 'disconnected' && hasConnectedRef.current) {
refreshSessionOnReconnectRef.current = true;
}
}, [loadSessionMessages, wsStatus]);
useEffect(() => {
const unsubMessage = wsManager.onMessage((data) => {
if (isSessionUpdatedEvent(data)) {
void loadSessions();
@ -263,11 +228,6 @@ export default function ChatPage() {
return;
}
if (isProcessEvent(data)) {
ingestProcessEvent(data);
return;
}
if (data.type === 'status' && data.status === 'thinking') {
setIsThinking(true);
} else if (data.type === 'message' && data.role === 'assistant') {
@ -284,12 +244,9 @@ export default function ChatPage() {
});
return () => {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
unsubStatus();
unsubMessage();
};
}, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]);
}, [addMessage, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
useEffect(() => {
if (!isLoading && !isThinking) {
@ -304,21 +261,34 @@ export default function ChatPage() {
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current;
if (!viewport) return;
messagesEndRef.current?.scrollIntoView({ block: 'end', behavior });
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
}, []);
const scheduleScrollToLatest = useCallback((behavior: ScrollBehavior) => {
if (typeof window === 'undefined') {
scrollMessagesToLatest(behavior);
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
scrollMessagesToLatest(behavior);
});
});
}, [scrollMessagesToLatest]);
useEffect(() => {
shouldSnapToLatestRef.current = true;
}, [sessionId]);
useLayoutEffect(() => {
if (messages.length === 0 && !isThinking && processEvents.length === 0) {
if (messages.length === 0 && !isThinking && sessionProcessEvents.length === 0) {
return;
}
scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
scheduleScrollToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
shouldSnapToLatestRef.current = false;
}, [isThinking, messages, processEvents, scrollMessagesToLatest]);
}, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]);
useEffect(() => {
if (!showCommandPicker || !pickerRef.current) return;
@ -348,7 +318,7 @@ export default function ChatPage() {
setPendingFiles([]);
setShowCommandPicker(false);
const msgContent = text || '(仅附件)';
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
addMessage({
role: 'user',
content: msgContent,
@ -392,12 +362,12 @@ export default function ChatPage() {
}
addMessage({
role: 'assistant',
content: '发送失败,请检查后端服务是否正在运行。',
content: pickAppText(locale, '发送失败,请检查后端服务是否正在运行。', 'Send failed. Please check whether the backend service is running.'),
timestamp: new Date().toISOString(),
});
}
}
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, pendingFiles, sessionId, setIsLoading, setIsThinking]);
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandPicker && filteredCommands.length > 0) {
@ -436,7 +406,7 @@ export default function ChatPage() {
for (const file of files) {
if (file.size > 50 * 1024 * 1024) {
setPendingFiles((prev) => [...prev, { file, progress: 0, error: '文件过大(最大 50MB' }]);
setPendingFiles((prev) => [...prev, { file, progress: 0, error: pickAppText(locale, '文件过大(最大 50MB', 'File is too large (max 50MB)') }]);
continue;
}
@ -447,16 +417,17 @@ export default function ChatPage() {
});
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item)));
} catch (err: any) {
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || '上传失败' } : item)));
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || pickAppText(locale, '上传失败', 'Upload failed') } : item)));
}
}
}, [sessionId]);
}, [locale, sessionId]);
const handleNewSession = async () => {
const id = `web:${Date.now()}`;
setSessionId(id);
setSelectedRunId(null);
clearMessages();
resetProcessState();
useChatStore.getState().resetProcessState();
try {
await createSession(id);
} catch {
@ -472,7 +443,7 @@ export default function ChatPage() {
if (key === sessionId) {
setSessionId('web:default');
clearMessages();
resetProcessState();
useChatStore.getState().resetProcessState();
}
loadSessions();
} catch {
@ -481,20 +452,21 @@ export default function ChatPage() {
};
const handleSelectSession = (key: string) => {
setSelectedRunId(null);
setSessionId(key);
};
const handleCancelRun = async (runId: string) => {
const handleCancelRun = useCallback(async (runId: string) => {
try {
await cancelDelegation(runId);
} catch (err: any) {
addMessage({
role: 'assistant',
content: `取消任务 ${runId} 失败:${err.message || '未知错误'}`,
content: pickAppText(locale, `取消任务 ${runId} 失败:${err.message || '未知错误'}`, `Failed to cancel task ${runId}: ${err.message || 'Unknown error'}`),
timestamp: new Date().toISOString(),
});
}
};
}, [addMessage, locale]);
const removePendingFile = useCallback((file: File) => {
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
@ -503,10 +475,10 @@ export default function ChatPage() {
const formatSessionName = (key: string) => {
if (key.startsWith('web:')) {
const id = key.slice(4);
if (id === 'default') return '默认';
if (id === 'default') return pickAppText(locale, '默认', 'Default');
const numeric = Number(id);
if (!Number.isNaN(numeric)) {
return new Date(numeric).toLocaleDateString('zh-CN', {
return new Date(numeric).toLocaleDateString(locale, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@ -524,14 +496,14 @@ export default function ChatPage() {
<div className="p-3">
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
<Plus className="w-4 h-4" />
{pickAppText(locale, '新对话', 'New chat')}
</Button>
</div>
<Separator />
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{sessions.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-4 text-center"></p>
<p className="text-xs text-muted-foreground px-2 py-4 text-center">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)}
{sessions.map((session) => (
<div
@ -567,22 +539,22 @@ export default function ChatPage() {
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Building2 className="h-4 w-4" />
{pickAppText(locale, '当前任务现场', 'Current task floor')}
</div>
<OfficeStatusBadge status={currentOfficeTask.status} />
</div>
<div className="mt-1 truncate text-sm text-muted-foreground">
{currentOfficeTask.title}
<span className="ml-2"> Agent: {currentOfficeTask.rootActorName}</span>
<span className="ml-2">{pickAppText(locale, '主 Agent', 'Lead agent')}: {currentOfficeTask.rootActorName}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/office"> Office</Link>
<Link href="/office">{pickAppText(locale, '查看全部 Office', 'View all office tasks')}</Link>
</Button>
<Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
{pickAppText(locale, '查看任务现场', 'Open task floor')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
@ -597,11 +569,11 @@ export default function ChatPage() {
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
messagesEndRef={messagesEndRef}
messageViewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={(runId) => setSelectedRunId(selectedRunId === runId ? null : runId)}
processRuns={sessionProcessRuns}
processEvents={sessionProcessEvents}
processArtifacts={sessionProcessArtifacts}
selectedRunId={selectedSessionRunId}
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
onCancelRun={handleCancelRun}
/>
</div>
@ -623,7 +595,7 @@ export default function ChatPage() {
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
</div>
) : (
<span className="text-green-500 text-xs"></span>
<span className="text-green-500 text-xs">{pickAppText(locale, '就绪', 'Ready')}</span>
)}
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
@ -660,7 +632,7 @@ export default function ChatPage() {
<span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span>
{command.plugin_name !== 'builtin' && (
<span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}>
{command.plugin_name === 'skill' ? '技能' : command.plugin_name}
{command.plugin_name === 'skill' ? pickAppText(locale, '技能', 'Skill') : command.plugin_name}
</span>
)}
</button>
@ -675,7 +647,7 @@ export default function ChatPage() {
variant="ghost"
size="icon"
className="h-10 w-10 flex-shrink-0"
title="添加附件"
title={pickAppText(locale, '添加附件', 'Add attachment')}
>
<Paperclip className="w-4 h-4" />
</Button>
@ -685,7 +657,7 @@ export default function ChatPage() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息或 / 呼出命令…回车发送Shift+回车换行)"
placeholder={pickAppText(locale, '输入消息或 / 呼出命令…回车发送Shift+回车换行)', 'Type a message or use / for commands... (Enter to send, Shift+Enter for a new line)')}
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
style={{ minHeight: '40px', maxHeight: '200px' }}