Files
beaver_project/app-instance/frontend/components/chat-workbench/MessageList.tsx
steven_li 6e9e74d1ee feat(engine): 添加运行时上下文支持并重构工具迭代限制
添加 RuntimeContext 类用于捕获模型运行时的日期时间信息,
包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。

同时增加最大上下文消息数和工具迭代次数的配置选项,
将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。

BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。

- 添加 RuntimeContext 类和相关渲染方法
- 增加 max_context_messages 和 max_tool_iterations 配置
- 移除 ValidationService 相关代码
- 更新消息记录中的验证状态字段
- 添加原始工具调用检测和回退处理
2026-05-26 11:18:35 +08:00

442 lines
16 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, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api';
import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText, shouldDisplayChatMessage } from '@/lib/chat-messages';
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
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('');
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={`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 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="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 whitespace-pre-wrap">{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-8 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-8 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-8 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={() => onFeedback(message.run_id!, 'abandon')}
className="inline-flex h-8 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">
<textarea
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-8 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-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{pickAppText(locale, '提交', 'Submit')}
</button>
</div>
</div>
)}
</>
)}
{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>
);
}
type AgentTeamGroup = {
rootRun: ProcessRun;
memberRuns: ProcessRun[];
startedAt: string;
};
const TERMINAL_RUN_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cancelled']);
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);
}
function parseTimelineTime(value?: string | null): number | null {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
function buildAgentTeamGroups(processRuns: ProcessRun[]): AgentTeamGroup[] {
const runMap = new Map(processRuns.map((run) => [run.run_id, run]));
const groups = new Map<string, AgentTeamGroup>();
for (const run of processRuns) {
if (run.actor_type !== 'agent') {
continue;
}
let root = run;
const seen = new Set<string>([run.run_id]);
let parentId = run.parent_run_id ?? null;
while (parentId) {
const parent = runMap.get(parentId);
if (!parent || seen.has(parent.run_id)) {
break;
}
root = parent;
seen.add(parent.run_id);
parentId = parent.parent_run_id ?? null;
}
const existing = groups.get(root.run_id);
if (existing) {
existing.memberRuns.push(run);
continue;
}
groups.set(root.run_id, {
rootRun: root,
memberRuns: [run],
startedAt: root.started_at || run.started_at,
});
}
return Array.from(groups.values())
.map((group) => ({
...group,
memberRuns: [...group.memberRuns].sort((a: ProcessRun, b: ProcessRun) => {
const at = parseTimelineTime(a.started_at) ?? 0;
const bt = parseTimelineTime(b.started_at) ?? 0;
return at - bt;
}),
}))
.sort((a, b) => {
const at = parseTimelineTime(a.startedAt) ?? 0;
const bt = parseTimelineTime(b.startedAt) ?? 0;
return at - bt;
});
}
export function MessageList({
messages,
isThinking,
messagesEndRef,
viewportRef,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
onSelectRun,
onFeedback,
onRequestRevision,
}: {
messages: ChatMessage[];
isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>;
viewportRef: React.RefObject<HTMLDivElement>;
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
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 teamGroups = React.useMemo(
() =>
buildAgentTeamGroups(processRuns).filter((group) =>
group.memberRuns.some((run) => !TERMINAL_RUN_STATUSES.has(run.status))
),
[processRuns]
);
const timelineItems = React.useMemo(() => {
const messageItems = visibleMessages.map((message, index) => ({
kind: 'message' as const,
key: `${message.role}:${message.timestamp || index}:${index}`,
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
order: index,
message,
messageIndex: index,
}));
const teamItems = teamGroups.map((group, index) => ({
kind: 'team' as const,
key: `team:${group.rootRun.run_id}`,
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + visibleMessages.length + index,
order: visibleMessages.length + index,
group,
}));
return [...messageItems, ...teamItems].sort((a, b) => {
if (a.sortTime !== b.sortTime) {
return a.sortTime - b.sortTime;
}
return a.order - b.order;
});
}, [teamGroups, visibleMessages]);
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-8" viewportRef={viewportRef}>
<div className="mx-auto max-w-5xl space-y-8 py-10">
{visibleMessages.length === 0 && teamGroups.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>
)}
{timelineItems.map((item) =>
item.kind === 'message' ? (
<MessageBubble
key={item.key}
message={item.message}
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
canSendFeedback={item.messageIndex === latestFeedbackMessageIndex}
onFeedback={onFeedback}
onRequestRevision={onRequestRevision}
/>
) : (
<AgentTeamBlock
key={item.key}
rootRun={item.group.rootRun}
memberRuns={item.group.memberRuns}
events={processEvents}
artifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
/>
)
)}
{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>
);
}