feat(task): 添加任务修订功能和超时处理机制
添加了 `revise_task` 路由动作类型,允许用户修改、纠正或重新执行最新活动任务结果。 实现了工具失败指导原则,防止相同类别工具重复失败。 为任务规划器添加了超时处理机制,避免长时间等待。 BREAKING CHANGE: 任务路由逻辑已更新,新增 `revise_task` 动作类型。 fix(api): 修复任务详情API返回完整流程投影 修复了任务详情API端点,现在会包含过滤后的流程运行、事件和工件信息, 并确保时间戳字段正确序列化。 refactor(engine): 优化任务技能解析器摘要节点处理 改进了任务技能解析器对摘要节点的处理逻辑,对于仅依赖文本生成功能的摘要节 点不再分配具体技能,直接使用依赖项输出进行汇总。 test: 增加任务修订和超时处理测试用例 添加了测试用例验证任务修订输入记录反馈、超时回退到单模式以及 摘要节点技能解析等新功能。
This commit is contained in:
@ -150,6 +150,7 @@ export default function NotificationDetailPage() {
|
||||
selectedRunId={null}
|
||||
onSelectRun={() => {}}
|
||||
onFeedback={() => {}}
|
||||
onRequestRevision={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -18,41 +18,12 @@ import {
|
||||
uploadFile,
|
||||
wsManager,
|
||||
} from '@/lib/api';
|
||||
import { mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||
|
||||
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 isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
|
||||
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
||||
}
|
||||
@ -101,11 +72,13 @@ export default function ChatPage() {
|
||||
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadSessionReqSeq = useRef(0);
|
||||
const loadedSessionIdRef = useRef<string | null>(null);
|
||||
const refreshSessionOnReconnectRef = useRef(false);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const shouldSnapToLatestRef = useRef(true);
|
||||
@ -185,10 +158,15 @@ export default function ChatPage() {
|
||||
}, [loadActiveTask, setIsLoading, setIsThinking, setMessages, setSessionProcess]);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages();
|
||||
setIsLoading(false);
|
||||
setIsThinking(false);
|
||||
const didSwitchSession = loadedSessionIdRef.current !== null && loadedSessionIdRef.current !== sessionId;
|
||||
loadedSessionIdRef.current = sessionId;
|
||||
if (didSwitchSession) {
|
||||
clearMessages();
|
||||
setIsLoading(false);
|
||||
setIsThinking(false);
|
||||
}
|
||||
setActiveTask(null);
|
||||
setRevisionTargetRunId(null);
|
||||
void loadSessionMessages(sessionId);
|
||||
void loadActiveTask(sessionId);
|
||||
}, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
||||
@ -304,10 +282,33 @@ export default function ChatPage() {
|
||||
size: item.file.size,
|
||||
}));
|
||||
|
||||
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
|
||||
|
||||
if (revisionTargetRunId && text) {
|
||||
setIsLoading(true);
|
||||
setIsThinking(false);
|
||||
updateMessageFeedback(revisionTargetRunId, 'revise');
|
||||
try {
|
||||
await submitChatFeedback({
|
||||
sessionId,
|
||||
runId: revisionTargetRunId,
|
||||
feedbackType: 'revise',
|
||||
comment: msgContent,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setIsThinking(false);
|
||||
setIsLoading(false);
|
||||
updateMessageFeedback(revisionTargetRunId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed'));
|
||||
return;
|
||||
} finally {
|
||||
setRevisionTargetRunId(null);
|
||||
}
|
||||
} else {
|
||||
setRevisionTargetRunId(null);
|
||||
}
|
||||
|
||||
setInput('');
|
||||
setPendingFiles([]);
|
||||
|
||||
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
|
||||
addMessage({
|
||||
role: 'user',
|
||||
content: msgContent,
|
||||
@ -371,7 +372,7 @@ export default function ChatPage() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled]);
|
||||
}, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
|
||||
|
||||
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
|
||||
updateMessageFeedback(runId, feedbackType);
|
||||
@ -391,6 +392,11 @@ export default function ChatPage() {
|
||||
}
|
||||
}, [loadActiveTask, loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
|
||||
|
||||
const handleRequestRevision = useCallback((runId: string) => {
|
||||
setRevisionTargetRunId(runId);
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
@ -426,6 +432,7 @@ export default function ChatPage() {
|
||||
setSessionId(id);
|
||||
setSelectedRunId(null);
|
||||
setActiveTask(null);
|
||||
setRevisionTargetRunId(null);
|
||||
clearMessages();
|
||||
useChatStore.getState().resetProcessState();
|
||||
try {
|
||||
@ -444,6 +451,7 @@ export default function ChatPage() {
|
||||
if (key === sessionId) {
|
||||
setSessionId('web:default');
|
||||
setActiveTask(null);
|
||||
setRevisionTargetRunId(null);
|
||||
clearMessages();
|
||||
useChatStore.getState().resetProcessState();
|
||||
}
|
||||
@ -460,6 +468,7 @@ export default function ChatPage() {
|
||||
const handleSelectSession = (key: string) => {
|
||||
setSelectedRunId(null);
|
||||
setActiveTask(null);
|
||||
setRevisionTargetRunId(null);
|
||||
setSessionId(key);
|
||||
};
|
||||
|
||||
@ -554,24 +563,29 @@ export default function ChatPage() {
|
||||
selectedRunId={selectedSessionRunId}
|
||||
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
|
||||
onFeedback={handleFeedback}
|
||||
onRequestRevision={handleRequestRevision}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-background px-8 pb-8 pt-4">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{activeTask && (
|
||||
{(activeTask || revisionTargetRunId) && (
|
||||
<div className="mb-2 flex">
|
||||
<Link
|
||||
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
|
||||
title={activeTask.description}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">{pickAppText(locale, '当前任务', 'Current task')}:</span>
|
||||
<span className="truncate font-medium">{activeTask.short_title}</span>
|
||||
<span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{activeTaskStatusLabel(activeTask.status, locale)}
|
||||
</span>
|
||||
</Link>
|
||||
{activeTask ? (
|
||||
<Link
|
||||
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
|
||||
title={activeTask.description}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{revisionTargetRunId ? pickAppText(locale, '修改任务', 'Revising task') : pickAppText(locale, '当前任务', 'Current task')}:
|
||||
</span>
|
||||
<span className="truncate font-medium">{activeTask.short_title}</span>
|
||||
<span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{revisionTargetRunId ? pickAppText(locale, '待输入修改要求', 'Awaiting revision') : activeTaskStatusLabel(activeTask.status, locale)}
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{pendingFiles.length > 0 && (
|
||||
@ -607,7 +621,11 @@ export default function ChatPage() {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')}
|
||||
placeholder={
|
||||
revisionTargetRunId
|
||||
? pickAppText(locale, '请输入修改要求', 'Describe the requested changes')
|
||||
: pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')
|
||||
}
|
||||
rows={1}
|
||||
className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ minHeight: '72px', maxHeight: '200px' }}
|
||||
|
||||
@ -15,7 +15,7 @@ import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent } from '@/types';
|
||||
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
type TaskFeedbackType = 'satisfied' | 'revise' | 'abandon';
|
||||
type TaskFeedbackItem = {
|
||||
@ -217,6 +217,8 @@ export default function TaskDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<BackendExecutionStages task={backendTask} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
||||
@ -549,6 +551,80 @@ function Metric({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BackendExecutionStages({ task }: { task: BackendTask }) {
|
||||
const { locale } = useAppI18n();
|
||||
const runs = task.process_runs ?? [];
|
||||
const events = task.process_events ?? [];
|
||||
const eventsByRun = React.useMemo(() => {
|
||||
const map = new Map<string, ProcessEvent[]>();
|
||||
for (const event of events) {
|
||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
||||
}
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '执行阶段', 'Execution stages')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{runs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无执行阶段记录', 'No execution stage records yet')}</div>
|
||||
) : (
|
||||
runs.map((run) => (
|
||||
<BackendProcessRun key={run.run_id} run={run} events={eventsByRun.get(run.run_id) ?? []} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendProcessRun({ run, events }: { run: ProcessRun; events: ProcessEvent[] }) {
|
||||
const { locale } = useAppI18n();
|
||||
const metadata = run.metadata ?? {};
|
||||
const details = [
|
||||
metadata.attempt_index ? `${pickAppText(locale, '尝试', 'Attempt')} ${String(metadata.attempt_index)}` : null,
|
||||
metadata.plan_mode ? `${pickAppText(locale, '模式', 'Mode')}: ${String(metadata.plan_mode)}` : null,
|
||||
metadata.strategy ? `${pickAppText(locale, '策略', 'Strategy')}: ${String(metadata.strategy)}` : null,
|
||||
metadata.node_id ? `${pickAppText(locale, '节点', 'Node')}: ${String(metadata.node_id)}` : null,
|
||||
metadata.finish_reason ? `${pickAppText(locale, '结束原因', 'Finish')}: ${String(metadata.finish_reason)}` : null,
|
||||
].filter(Boolean);
|
||||
const error = typeof metadata.error === 'string' && metadata.error ? metadata.error : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{run.title || run.actor_name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{run.actor_name}
|
||||
{run.started_at ? ` · ${formatTaskRuntimeTime(run.started_at, locale)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={run.status} />
|
||||
</div>
|
||||
{details.length > 0 ? <div className="mt-2 text-xs text-muted-foreground">{details.join(' · ')}</div> : null}
|
||||
{run.summary ? <p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{run.summary}</p> : null}
|
||||
{error ? <p className="mt-2 text-sm text-destructive">{error}</p> : null}
|
||||
{events.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{events.map((event) => (
|
||||
<div key={event.event_id} className="rounded-md bg-muted/30 px-3 py-2 text-xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{event.actor_name}</span>
|
||||
<span className="text-muted-foreground">{formatTaskRuntimeTime(event.created_at, locale)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">{event.text || event.kind}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskFeedbackPanel({
|
||||
sessionId,
|
||||
runId,
|
||||
|
||||
@ -16,6 +16,7 @@ export function ChatWorkbench({
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onFeedback,
|
||||
onRequestRevision,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isThinking: boolean;
|
||||
@ -27,6 +28,7 @@ export function ChatWorkbench({
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onRequestRevision: (runId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
@ -41,6 +43,7 @@ export function ChatWorkbench({
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={onSelectRun}
|
||||
onFeedback={onFeedback}
|
||||
onRequestRevision={onRequestRevision}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -6,6 +6,7 @@ import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, Thumbs
|
||||
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||
import { getTaskCardMessageIndexes } 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';
|
||||
@ -40,17 +41,21 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
showTaskCard,
|
||||
canSendFeedback,
|
||||
onFeedback,
|
||||
onRequestRevision,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
showTaskCard: boolean;
|
||||
canSendFeedback: boolean;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onRequestRevision: (runId: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const isUser = message.role === 'user';
|
||||
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
|
||||
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | 'revise' | null>(null);
|
||||
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | null>(null);
|
||||
const [feedbackComment, setFeedbackComment] = React.useState('');
|
||||
const validationFailed = message.validation_status === 'failed';
|
||||
const validationDetails =
|
||||
@ -118,7 +123,7 @@ function MessageBubble({
|
||||
) : (
|
||||
<MarkdownContent content={textContent} />
|
||||
)}
|
||||
{!isUser && message.task_id && (
|
||||
{!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">
|
||||
@ -145,7 +150,7 @@ function MessageBubble({
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{validationDetails}</p>
|
||||
</details>
|
||||
)}
|
||||
{!isUser && canSendFeedback && message.run_id && (
|
||||
{!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">
|
||||
@ -171,7 +176,7 @@ function MessageBubble({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackMode('revise')}
|
||||
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" />
|
||||
@ -191,11 +196,7 @@ function MessageBubble({
|
||||
<textarea
|
||||
value={feedbackComment}
|
||||
onChange={(event) => setFeedbackComment(event.target.value)}
|
||||
placeholder={
|
||||
feedbackMode === 'revise'
|
||||
? pickAppText(locale, '写下需要修改的地方...', 'Describe what needs to change...')
|
||||
: pickAppText(locale, '可选:补充说明...', 'Optional note...')
|
||||
}
|
||||
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">
|
||||
@ -330,6 +331,7 @@ export function MessageList({
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onFeedback,
|
||||
onRequestRevision,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isThinking: boolean;
|
||||
@ -341,6 +343,7 @@ export function MessageList({
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
onRequestRevision: (runId: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const visibleMessages = React.useMemo(
|
||||
@ -361,6 +364,7 @@ export function MessageList({
|
||||
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,
|
||||
@ -377,9 +381,18 @@ export function MessageList({
|
||||
return a.order - b.order;
|
||||
});
|
||||
}, [teamGroups, visibleMessages]);
|
||||
const latestAssistantRunId = [...visibleMessages]
|
||||
const taskCardMessageIndexes = React.useMemo(
|
||||
() => getTaskCardMessageIndexes(visibleMessages),
|
||||
[visibleMessages]
|
||||
);
|
||||
const latestFeedbackRunId = [...visibleMessages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant' && message.run_id && message.task_id)?.run_id;
|
||||
.find((message) =>
|
||||
message.role === 'assistant'
|
||||
&& message.run_id
|
||||
&& message.task_id
|
||||
&& message.task_status === 'awaiting_feedback'
|
||||
)?.run_id;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
||||
@ -397,8 +410,10 @@ export function MessageList({
|
||||
<MessageBubble
|
||||
key={item.key}
|
||||
message={item.message}
|
||||
canSendFeedback={Boolean(latestAssistantRunId && item.message.run_id === latestAssistantRunId)}
|
||||
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
|
||||
canSendFeedback={Boolean(latestFeedbackRunId && item.message.run_id === latestFeedbackRunId)}
|
||||
onFeedback={onFeedback}
|
||||
onRequestRevision={onRequestRevision}
|
||||
/>
|
||||
) : (
|
||||
<AgentTeamBlock
|
||||
|
||||
74
app-instance/frontend/lib/chat-messages.test.ts
Normal file
74
app-instance/frontend/lib/chat-messages.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
describe('chat message helpers', () => {
|
||||
it('keeps pending local user messages after server refreshes', () => {
|
||||
const serverMessages: ChatMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Earlier answer',
|
||||
timestamp: '2026-05-21T08:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const localMessages: ChatMessage[] = [
|
||||
...serverMessages,
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Please continue',
|
||||
timestamp: '2026-05-21T08:01:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
expect(mergeServerWithPendingUsers(serverMessages, localMessages)).toEqual([
|
||||
...serverMessages,
|
||||
localMessages[1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate local user messages already persisted by the server', () => {
|
||||
const serverMessages: ChatMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Please continue',
|
||||
timestamp: '2026-05-21T08:01:00.000Z',
|
||||
},
|
||||
];
|
||||
const localMessages: ChatMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Please continue',
|
||||
timestamp: '2026-05-21T08:01:01.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
expect(mergeServerWithPendingUsers(serverMessages, localMessages)).toEqual(serverMessages);
|
||||
});
|
||||
|
||||
it('shows a task card only on the latest assistant message for the same task', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'I will start.',
|
||||
run_id: 'run-1',
|
||||
task_id: 'task-1',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'I found data.',
|
||||
run_id: 'run-1',
|
||||
task_id: 'task-1',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Final answer.',
|
||||
run_id: 'run-1',
|
||||
task_id: 'task-1',
|
||||
task_status: 'awaiting_feedback',
|
||||
},
|
||||
];
|
||||
|
||||
expect(Array.from(getTaskCardMessageIndexes(messages))).toEqual([2]);
|
||||
});
|
||||
});
|
||||
44
app-instance/frontend/lib/chat-messages.ts
Normal file
44
app-instance/frontend/lib/chat-messages.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
export 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}`;
|
||||
}
|
||||
|
||||
export 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];
|
||||
}
|
||||
|
||||
export function getTaskCardMessageIndexes(messages: ChatMessage[]): Set<number> {
|
||||
const latestByTask = new Map<string, number>();
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (message.role !== 'assistant' || !message.task_id) {
|
||||
return;
|
||||
}
|
||||
latestByTask.set(message.task_id, index);
|
||||
});
|
||||
|
||||
return new Set(latestByTask.values());
|
||||
}
|
||||
@ -342,6 +342,9 @@ export interface BackendTask {
|
||||
metadata: Record<string, unknown>;
|
||||
events?: BackendTaskEvent[];
|
||||
runs?: BackendTaskRun[];
|
||||
process_runs?: ProcessRun[];
|
||||
process_events?: ProcessEvent[];
|
||||
process_artifacts?: ProcessArtifact[];
|
||||
}
|
||||
|
||||
export interface ActiveTask {
|
||||
|
||||
Reference in New Issue
Block a user