添加 DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS 配置项以控制团队节点的最大工具迭代次数, 并修改 LocalAgentRunner 中的逻辑来使用此默认值当 envelope 中未指定时。 fix(runtime): 修复团队节点运行成功判断逻辑 更新运行成功判断条件,将 finish_reason 为 "max_tool_iterations_finalized" 的情况 视为运行失败,并添加对原始工具调用输出的检测,避免将其误判为成功完成。 feat(mcp): 添加团队工作流MCP工具类别支持 增加新的本地MCP工具类别 "team_workflow" 及其对应的工具创建功能, 为团队工作流提供本地工具支持。 refactor(engine): 调整AgentLoop最大工具迭代次数设置 将 AgentProfile 中的默认 max_tool_iterations 从 30 增加到 100, 同时移除 TaskExecutionPlanner 构造函数中的重复参数传递。 perf(mcp): 优化MCP连接管理避免重复连接 添加 mcp_connected 标志来跟踪MCP连接状态,确保 connect_all 只执行一次, 提高性能并避免不必要的重复连接。 refactor(skills): 移除技能团队模板相关功能 移除与技能团队模板相关的代码,包括解析、存储和处理逻辑, 简化技能记录结构和加载流程。 feat(process): 增强会话过程投影器功能 添加技能激活快照事件处理,改进团队运行完成消息显示, 并增强技能激活事件的时间戳记录功能。 refactor(tasks): 简化任务尝试编排器团队执行逻辑 移除团队执行相关代码,将所有任务统一按单步执行处理, 简化任务编排器的复杂度并提升执行效率。 fix(evidence): 修复节点证据评估中需求验证逻辑 更新节点证据评估逻辑,跳过自然语言证据需求的确定性验证, 只执行机器可读的需求验证,避免因自然语言需求导致的节点失败。
368 lines
15 KiB
TypeScript
368 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import Link from 'next/link';
|
|
import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react';
|
|
|
|
import type { ChatMessage } from '@/types';
|
|
import { getAccessToken, getFileUrl } from '@/lib/api';
|
|
import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText, shouldDisplayChatMessage } from '@/lib/chat-messages';
|
|
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { pickAppText } from '@/lib/i18n/core';
|
|
import { useAppI18n } from '@/lib/i18n/provider';
|
|
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
|
|
|
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
|
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
const token = getAccessToken();
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
|
|
let revoke: string | null = null;
|
|
fetch(src, { headers })
|
|
.then((res) => res.blob())
|
|
.then((blob) => {
|
|
revoke = URL.createObjectURL(blob);
|
|
setBlobUrl(revoke);
|
|
})
|
|
.catch(() => {});
|
|
|
|
return () => {
|
|
if (revoke) URL.revokeObjectURL(revoke);
|
|
};
|
|
}, [src]);
|
|
|
|
if (!blobUrl) return <div className="w-32 h-32 bg-muted animate-pulse rounded" />;
|
|
return <img src={blobUrl} alt={alt} className={className} loading="lazy" decoding="async" />;
|
|
}
|
|
|
|
function MessageBubble({
|
|
message,
|
|
showTaskCard,
|
|
canSendFeedback,
|
|
onFeedback,
|
|
onRequestRevision,
|
|
}: {
|
|
message: ChatMessage;
|
|
showTaskCard: boolean;
|
|
canSendFeedback: boolean;
|
|
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
|
|
onRequestRevision: (runId: string) => void;
|
|
}) {
|
|
const { locale } = useAppI18n();
|
|
const isUser = message.role === 'user';
|
|
const textContent = normalizedMessageText(message.content);
|
|
const [feedbackMode, setFeedbackMode] = React.useState<'accept' | null>(null);
|
|
const [feedbackComment, setFeedbackComment] = React.useState('');
|
|
const [confirmAbandonOpen, setConfirmAbandonOpen] = React.useState(false);
|
|
const feedbackTextareaId = message.run_id ? `feedback-note-${message.run_id}` : undefined;
|
|
|
|
return (
|
|
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
|
{!isUser && (
|
|
<div className="mt-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-[#F1EFEE]">
|
|
<Bot className="w-4 h-4 text-primary" />
|
|
</div>
|
|
)}
|
|
<div
|
|
className={`min-w-0 max-w-[88%] px-4 py-3 ${
|
|
isUser
|
|
? 'rounded-[28px] bg-primary text-primary-foreground'
|
|
: 'rounded-none bg-transparent text-[#1D1715]'
|
|
}`}
|
|
>
|
|
{message.attachments && message.attachments.length > 0 && (
|
|
<div className="mb-2 space-y-2">
|
|
{message.attachments.map((att) => {
|
|
const fileUrl = getFileUrl(att.file_id);
|
|
if (att.content_type.startsWith('image/')) {
|
|
return (
|
|
<a key={att.file_id} href={fileUrl} target="_blank" rel="noopener noreferrer">
|
|
<AuthImage
|
|
src={fileUrl}
|
|
alt={att.name}
|
|
className="max-w-xs max-h-60 rounded border border-border/50 cursor-pointer hover:opacity-90"
|
|
/>
|
|
</a>
|
|
);
|
|
}
|
|
return (
|
|
<a
|
|
key={att.file_id}
|
|
href={fileUrl}
|
|
download={att.name}
|
|
className={`flex min-w-0 items-center gap-2 px-3 py-2 rounded-md text-sm ${
|
|
isUser
|
|
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
|
|
: 'bg-muted hover:bg-muted/80'
|
|
}`}
|
|
>
|
|
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span className="min-w-0 truncate">{att.name}</span>
|
|
{att.size && (
|
|
<span className="text-xs opacity-70 flex-shrink-0">
|
|
{att.size > 1024 * 1024
|
|
? `${(att.size / 1024 / 1024).toFixed(1)}MB`
|
|
: `${(att.size / 1024).toFixed(0)}KB`}
|
|
</span>
|
|
)}
|
|
</a>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{isUser ? (
|
|
<p className={`text-sm ${containedPreservedLongTextClass}`}>{textContent}</p>
|
|
) : (
|
|
<MarkdownContent content={textContent} />
|
|
)}
|
|
{!isUser && showTaskCard && message.task_id && (
|
|
<div className="mt-3 rounded-md border border-border bg-muted/35 p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-medium uppercase text-muted-foreground">Task</div>
|
|
<div className="mt-1 truncate text-sm font-medium">
|
|
{pickAppText(locale, '已创建任务', 'Task created')}: {message.task_id}
|
|
</div>
|
|
</div>
|
|
<Link
|
|
href={`/tasks/${encodeURIComponent(message.task_id)}`}
|
|
className="inline-flex h-11 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
{pickAppText(locale, '查看任务', 'Open task')}
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!isUser && (canSendFeedback || message.feedback_state) && message.run_id && (
|
|
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
|
|
{message.feedback_state ? (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
<span>
|
|
{message.feedback_state === 'accept' || message.feedback_state === 'satisfied'
|
|
? pickAppText(locale, '已接受', 'Accepted')
|
|
: message.feedback_state === 'revise'
|
|
? pickAppText(locale, '已请求修改', 'Revision requested')
|
|
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFeedbackMode('accept')}
|
|
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ThumbsUp className="h-3.5 w-3.5" />
|
|
{pickAppText(locale, '接受', 'Accept')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onRequestRevision(message.run_id!)}
|
|
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<RefreshCcw className="h-3.5 w-3.5" />
|
|
{pickAppText(locale, '需要修改', 'Revise')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmAbandonOpen(true)}
|
|
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
{pickAppText(locale, '放弃', 'Abandon')}
|
|
</button>
|
|
</div>
|
|
{feedbackMode && (
|
|
<div className="space-y-2 rounded-md border border-border bg-background p-2">
|
|
<label htmlFor={feedbackTextareaId} className="sr-only">
|
|
{pickAppText(locale, '接受反馈备注', 'Acceptance note')}
|
|
</label>
|
|
<textarea
|
|
id={feedbackTextareaId}
|
|
value={feedbackComment}
|
|
onChange={(event) => setFeedbackComment(event.target.value)}
|
|
placeholder={pickAppText(locale, '可选:补充说明...', 'Optional note...')}
|
|
className="min-h-20 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setFeedbackMode(null);
|
|
setFeedbackComment('');
|
|
}}
|
|
className="h-11 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
|
|
>
|
|
{pickAppText(locale, '取消', 'Cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onFeedback(message.run_id!, feedbackMode, feedbackComment.trim() || undefined)}
|
|
className="h-11 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
{pickAppText(locale, '提交', 'Submit')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<Dialog open={confirmAbandonOpen} onOpenChange={setConfirmAbandonOpen}>
|
|
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{pickAppText(locale, '放弃当前任务?', 'Abandon this task?')}</DialogTitle>
|
|
<DialogDescription>
|
|
{pickAppText(locale, '放弃后会停止等待该任务的验收结果,此操作需要明确确认。', 'This stops waiting for this task acceptance result and requires confirmation.')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-2 sm:gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmAbandonOpen(false)}
|
|
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
|
|
>
|
|
{pickAppText(locale, '取消', 'Cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setConfirmAbandonOpen(false);
|
|
onFeedback(message.run_id!, 'abandon');
|
|
}}
|
|
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{pickAppText(locale, '确认放弃', 'Confirm abandon')}
|
|
</button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
{message.feedback_error && (
|
|
<span className="text-xs text-destructive">{message.feedback_error}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isUser && (
|
|
<div className="mt-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-secondary">
|
|
<User className="w-4 h-4" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function shouldHideSystemAgentMessage(message: ChatMessage): boolean {
|
|
if (message.role !== 'assistant' || typeof message.content !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const content = message.content.trim();
|
|
return (
|
|
/^\[(Agent team|Subagent)\s+['"][^'"]+['"]\s+(completed|failed|cancelled|finished)\]/i.test(content)
|
|
|| (content.startsWith('[Agent team ') && content.includes('\nTask:'))
|
|
);
|
|
}
|
|
|
|
function hasRenderableMessageContent(message: ChatMessage): boolean {
|
|
return hasVisibleChatContent(message);
|
|
}
|
|
|
|
function shouldHideMessage(message: ChatMessage): boolean {
|
|
if (shouldHideSystemAgentMessage(message)) {
|
|
return true;
|
|
}
|
|
return !shouldDisplayChatMessage(message);
|
|
}
|
|
|
|
export function MessageList({
|
|
messages,
|
|
isThinking,
|
|
messagesEndRef,
|
|
viewportRef,
|
|
onFeedback,
|
|
onRequestRevision,
|
|
}: {
|
|
messages: ChatMessage[];
|
|
isThinking: boolean;
|
|
messagesEndRef: React.RefObject<HTMLDivElement>;
|
|
viewportRef: React.RefObject<HTMLDivElement>;
|
|
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
|
|
onRequestRevision: (runId: string) => void;
|
|
}) {
|
|
const { locale } = useAppI18n();
|
|
const visibleMessages = React.useMemo(
|
|
() => messages.filter((message) => !shouldHideMessage(message)),
|
|
[messages]
|
|
);
|
|
const taskCardMessageIndexes = React.useMemo(
|
|
() => getTaskCardMessageIndexes(visibleMessages),
|
|
[visibleMessages]
|
|
);
|
|
const latestFeedbackMessageIndex = (() => {
|
|
for (let index = visibleMessages.length - 1; index >= 0; index -= 1) {
|
|
const message = visibleMessages[index];
|
|
if (
|
|
message.role === 'assistant'
|
|
&& message.run_id
|
|
&& message.task_id
|
|
&& message.task_status === 'awaiting_acceptance'
|
|
&& hasRenderableMessageContent(message)
|
|
) {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
})();
|
|
|
|
return (
|
|
<ScrollArea className="h-full px-3 sm:px-5 md:px-8" viewportRef={viewportRef}>
|
|
<div className="mx-auto max-w-5xl space-y-8 py-6 md:py-10">
|
|
{visibleMessages.length === 0 && !isThinking && (
|
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
|
<p className="text-lg font-medium text-foreground">Beaver</p>
|
|
<p className="text-sm">{pickAppText(locale, '发送消息开始对话', 'Send a message to start the conversation')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{visibleMessages.map((message, index) => (
|
|
<MessageBubble
|
|
key={`${message.role}:${message.timestamp || index}:${index}`}
|
|
message={message}
|
|
showTaskCard={taskCardMessageIndexes.has(index)}
|
|
canSendFeedback={index === latestFeedbackMessageIndex}
|
|
onFeedback={onFeedback}
|
|
onRequestRevision={onRequestRevision}
|
|
/>
|
|
))}
|
|
|
|
{isThinking && (
|
|
<div className="flex items-center gap-2 text-muted-foreground px-1">
|
|
<Bot className="w-5 h-5" />
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
<span className="text-sm">{pickAppText(locale, '思考中...', 'Thinking...')}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|