添加 RuntimeContext 类用于捕获模型运行时的日期时间信息, 包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。 同时增加最大上下文消息数和工具迭代次数的配置选项, 将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。 BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。 - 添加 RuntimeContext 类和相关渲染方法 - 增加 max_context_messages 和 max_tool_iterations 配置 - 移除 ValidationService 相关代码 - 更新消息记录中的验证状态字段 - 添加原始工具调用检测和回退处理
442 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|