Files
beaver_project/app-instance/frontend/app/(app)/page.tsx
steven_li 29dfd14aa6 ```
feat(agent): 添加对持久化子智能体的支持并增强委派管理

添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。
新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。

同时增强了委派管理器的功能:
- 添加了对本地委派、插件委派和本地回退的开关控制
- 实现了持久化子智能体任务的自动检测和本地执行保护
- 增加了对不同委派类型的权限验证机制

修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。

BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。
```
2026-03-27 10:15:35 +08:00

714 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import Link from 'next/link';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ArrowRight, Building2, MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
import { OfficeStatusBadge } from '@/components/office/OfficeShared';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
cancelDelegation,
createSession,
deleteSession,
getSession,
getStatus,
listCommands,
listSessions,
sendMessage,
uploadFile,
wsManager,
} from '@/lib/api';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
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);
}
function messageFingerprint(msg: ChatMessage): string {
const attachmentKey = (msg.attachments ?? [])
.map((a) => `${a.file_id ?? ''}:${a.name}:${a.content_type}:${a.size ?? ''}`)
.join('|');
return `${msg.role}::${String(msg.content)}::${attachmentKey}`;
}
function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessages: ChatMessage[]): ChatMessage[] {
const counts = new Map<string, number>();
for (const message of serverMessages) {
const key = messageFingerprint(message);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const pendingUsers: ChatMessage[] = [];
for (const message of localMessages) {
const key = messageFingerprint(message);
const count = counts.get(key) ?? 0;
if (count > 0) {
counts.set(key, count - 1);
continue;
}
if (message.role === 'user') {
pendingUsers.push(message);
}
}
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 {
sessionId,
messages,
isLoading,
isThinking,
sessions,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
setSessionId,
setMessages,
addMessage,
setIsLoading,
setSessions,
clearMessages,
setWsStatus,
setIsThinking,
setNanobotReady,
resetProcessState,
ingestProcessEvent,
setSelectedRunId,
} = useChatStore();
const [input, setInput] = useState('');
const [commands, setCommands] = useState<SlashCommand[]>([]);
const [showCommandPicker, setShowCommandPicker] = useState(false);
const [pickerIndex, setPickerIndex] = useState(0);
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadSessionReqSeq = useRef(0);
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 filteredCommands = useMemo(() => {
if (!input.startsWith('/') || input.includes(' ')) return [];
const filter = input.slice(1).toLowerCase();
return commands.filter(
(command) => command.name.startsWith(filter) || (filter === '' ? true : command.name.includes(filter))
);
}, [commands, input]);
const officeTasks = useMemo(
() => buildOfficeTaskList({
sessionId,
sessions,
processRuns,
processEvents,
processArtifacts,
}),
[processArtifacts, processEvents, processRuns, sessionId, sessions]
);
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
const loadSessions = useCallback(async () => {
try {
const list = await listSessions();
setSessions(list);
} catch {
// backend may be offline during first render
}
}, [setSessions]);
const loadSessionMessages = useCallback(async (key: string) => {
const reqSeq = ++loadSessionReqSeq.current;
const localSnapshot = useChatStore.getState().messages;
const waitingForReply = useChatStore.getState().isLoading || useChatStore.getState().isThinking;
try {
const detail = await getSession(key);
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
const nextMessages = waitingForReply
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages;
setMessages(nextMessages);
const last = nextMessages[nextMessages.length - 1];
if (last?.role === 'assistant') {
setIsThinking(false);
setIsLoading(false);
}
} catch {
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
}
}, [setIsLoading, setIsThinking, setMessages]);
const loadCommands = useCallback(async () => {
if (commandsLoadedRef.current) return;
commandsLoadedRef.current = true;
try {
const nextCommands = await listCommands();
setCommands(nextCommands);
} catch {
commandsLoadedRef.current = false;
}
}, []);
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();
}
}, [input, loadCommands]);
useEffect(() => {
setShowCommandPicker(filteredCommands.length > 0);
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]);
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);
}
});
const unsubMessage = wsManager.onMessage((data) => {
if (isSessionUpdatedEvent(data)) {
void loadSessions();
if (data.session_id === useChatStore.getState().sessionId) {
void loadSessionMessages(data.session_id);
}
return;
}
if (isProcessEvent(data)) {
ingestProcessEvent(data);
return;
}
if (data.type === 'status' && data.status === 'thinking') {
setIsThinking(true);
} else if (data.type === 'message' && data.role === 'assistant') {
setIsThinking(false);
setIsLoading(false);
addMessage({
role: 'assistant',
content: typeof data.content === 'string' ? data.content : '',
timestamp: new Date().toISOString(),
attachments: Array.isArray(data.attachments) ? data.attachments : undefined,
});
loadSessions();
}
});
return () => {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
unsubStatus();
unsubMessage();
};
}, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]);
useEffect(() => {
if (!isLoading && !isThinking) {
return;
}
const timer = setInterval(() => {
loadSessionMessages(useChatStore.getState().sessionId);
}, 1500);
return () => clearInterval(timer);
}, [isLoading, isThinking, loadSessionMessages]);
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current;
if (!viewport) return;
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
}, []);
useEffect(() => {
shouldSnapToLatestRef.current = true;
}, [sessionId]);
useLayoutEffect(() => {
if (messages.length === 0 && !isThinking && processEvents.length === 0) {
return;
}
scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
shouldSnapToLatestRef.current = false;
}, [isThinking, messages, processEvents, scrollMessagesToLatest]);
useEffect(() => {
if (!showCommandPicker || !pickerRef.current) return;
const item = pickerRef.current.children[pickerIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}, [pickerIndex, showCommandPicker]);
const selectCommand = useCallback((command: SlashCommand) => {
setInput(command.argument_hint ? `/${command.name} ` : `/${command.name}`);
setShowCommandPicker(false);
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const text = input.trim();
if ((!text && pendingFiles.length === 0) || isLoading) return;
const readyFiles = pendingFiles.filter((p) => p.id && !p.error);
const attachments: FileAttachment[] = readyFiles.map((item) => ({
file_id: item.id!,
name: item.file.name,
content_type: item.file.type || 'application/octet-stream',
size: item.file.size,
}));
setInput('');
setPendingFiles([]);
setShowCommandPicker(false);
const msgContent = text || '(仅附件)';
addMessage({
role: 'user',
content: msgContent,
timestamp: new Date().toISOString(),
attachments: attachments.length > 0 ? attachments : undefined,
});
setIsLoading(true);
setIsThinking(false);
if (wsManager.getStatus() === 'connected') {
const wsPayload: Record<string, unknown> = { type: 'message', content: msgContent };
if (attachments.length > 0) {
wsPayload.attachments = attachments;
}
wsManager.sendRaw(wsPayload);
} else {
try {
const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined);
setIsThinking(false);
setIsLoading(false);
if (result.response) {
if (useChatStore.getState().sessionId !== sessionId) {
await loadSessions();
return;
}
addMessage({
role: 'assistant',
content: result.response,
timestamp: new Date().toISOString(),
});
loadSessions();
} else {
await loadSessionMessages(sessionId);
loadSessions();
}
} catch {
setIsThinking(false);
setIsLoading(false);
if (useChatStore.getState().sessionId !== sessionId) {
return;
}
addMessage({
role: 'assistant',
content: '发送失败,请检查后端服务是否正在运行。',
timestamp: new Date().toISOString(),
});
}
}
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, pendingFiles, sessionId, setIsLoading, setIsThinking]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandPicker && filteredCommands.length > 0) {
if (e.key === 'ArrowUp') {
e.preventDefault();
setPickerIndex((i) => (i <= 0 ? filteredCommands.length - 1 : i - 1));
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setPickerIndex((i) => (i >= filteredCommands.length - 1 ? 0 : i + 1));
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing)) {
e.preventDefault();
selectCommand(filteredCommands[pickerIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowCommandPicker(false);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
e.target.value = '';
for (const file of files) {
if (file.size > 50 * 1024 * 1024) {
setPendingFiles((prev) => [...prev, { file, progress: 0, error: '文件过大(最大 50MB' }]);
continue;
}
setPendingFiles((prev) => [...prev, { file, progress: 0 }]);
try {
const result = await uploadFile(file, sessionId, (pct) => {
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, progress: pct } : item)));
});
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)));
}
}
}, [sessionId]);
const handleNewSession = async () => {
const id = `web:${Date.now()}`;
setSessionId(id);
clearMessages();
resetProcessState();
try {
await createSession(id);
} catch {
// ignore transient create failures; first message can still create the session server-side
}
void loadSessions();
};
const handleDeleteSession = async (key: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await deleteSession(key);
if (key === sessionId) {
setSessionId('web:default');
clearMessages();
resetProcessState();
}
loadSessions();
} catch {
// ignore transient errors
}
};
const handleSelectSession = (key: string) => {
setSessionId(key);
};
const handleCancelRun = async (runId: string) => {
try {
await cancelDelegation(runId);
} catch (err: any) {
addMessage({
role: 'assistant',
content: `取消任务 ${runId} 失败:${err.message || '未知错误'}`,
timestamp: new Date().toISOString(),
});
}
};
const removePendingFile = useCallback((file: File) => {
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
}, []);
const formatSessionName = (key: string) => {
if (key.startsWith('web:')) {
const id = key.slice(4);
if (id === 'default') return '默认';
const numeric = Number(id);
if (!Number.isNaN(numeric)) {
return new Date(numeric).toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
return id;
}
return key;
};
return (
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
<div className="w-64 border-r border-border flex flex-col bg-card">
<div className="p-3">
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
<Plus className="w-4 h-4" />
</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>
)}
{sessions.map((session) => (
<div
key={session.key}
onClick={() => handleSelectSession(session.key)}
className={`group flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer text-sm ${
session.key === sessionId
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50'
}`}
>
<div className="flex items-center gap-2 truncate">
<MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{formatSessionName(session.key)}</span>
</div>
<button
onClick={(event) => handleDeleteSession(session.key, event)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</ScrollArea>
</div>
<div className="flex-1 flex flex-col min-w-0">
{currentOfficeTask ? (
<div className="border-b border-border bg-background/90 px-4 py-3 backdrop-blur">
<div className="mx-auto flex max-w-6xl flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<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" />
</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>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/office"> Office</Link>
</Button>
<Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
) : null}
<div className="flex-1 min-h-0">
<ChatWorkbench
messages={messages}
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)}
onCancelRun={handleCancelRun}
/>
</div>
<div className="border-t border-border p-4 bg-background/95 backdrop-blur">
<div className="max-w-5xl mx-auto">
{pendingFiles.length > 0 && (
<div className="mb-2 space-y-1">
{pendingFiles.map((item, index) => (
<div key={`${item.file.name}:${index}`} className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm">
<span className="truncate flex-1">
{item.file.name}{' '}
<span className="text-muted-foreground">({(item.file.size / 1024).toFixed(0)}KB)</span>
</span>
{item.error ? (
<span className="text-destructive text-xs">{item.error}</span>
) : item.progress < 100 ? (
<div className="w-20 h-1.5 bg-muted-foreground/20 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
</div>
) : (
<span className="text-green-500 text-xs"></span>
)}
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<div className="relative flex gap-2">
{showCommandPicker && filteredCommands.length > 0 && (
<div
ref={pickerRef}
className="absolute bottom-full left-0 right-10 mb-2 bg-popover border border-border rounded-lg shadow-lg overflow-y-auto max-h-60 z-50"
>
{filteredCommands.map((command, index) => (
<button
key={command.name}
className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm transition-colors ${
index === pickerIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50 text-foreground'
}`}
onMouseDown={(event) => {
event.preventDefault();
selectCommand(command);
}}
onMouseEnter={() => setPickerIndex(index)}
>
<span className="font-mono font-semibold text-primary shrink-0">/{command.name}</span>
{command.argument_hint && (
<span className="text-muted-foreground text-xs shrink-0">{command.argument_hint}</span>
)}
<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}
</span>
)}
</button>
))}
</div>
)}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<Button
onClick={() => fileInputRef.current?.click()}
variant="ghost"
size="icon"
className="h-10 w-10 flex-shrink-0"
title="添加附件"
>
<Paperclip className="w-4 h-4" />
</Button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息或 / 呼出命令…回车发送Shift+回车换行)"
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' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${Math.min(target.scrollHeight, 200)}px`;
}}
/>
<Button
onClick={handleSend}
disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading}
size="icon"
className="h-10 w-10 flex-shrink-0"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}