Refine chat tool status dedupe (#786)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
32d14b8cf9
commit
25b13ab912
@@ -18,6 +18,7 @@ interface ChatMessageProps {
|
||||
message: RawMessage;
|
||||
showThinking: boolean;
|
||||
suppressToolCards?: boolean;
|
||||
suppressProcessAttachments?: boolean;
|
||||
isStreaming?: boolean;
|
||||
streamingTools?: Array<{
|
||||
id?: string;
|
||||
@@ -42,6 +43,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
message,
|
||||
showThinking,
|
||||
suppressToolCards = false,
|
||||
suppressProcessAttachments = false,
|
||||
isStreaming = false,
|
||||
streamingTools = [],
|
||||
}: ChatMessageProps) {
|
||||
@@ -55,8 +57,12 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
const tools = extractToolUse(message);
|
||||
const visibleThinking = showThinking ? thinking : null;
|
||||
const visibleTools = suppressToolCards ? [] : tools;
|
||||
const shouldHideProcessAttachments = suppressProcessAttachments
|
||||
&& (hasText || !!visibleThinking || images.length > 0 || visibleTools.length > 0);
|
||||
|
||||
const attachedFiles = message._attachedFiles || [];
|
||||
const attachedFiles = shouldHideProcessAttachments
|
||||
? (message._attachedFiles || []).filter((file) => file.source !== 'tool-result')
|
||||
: (message._attachedFiles || []);
|
||||
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);
|
||||
|
||||
// Never render tool result messages in chat UI
|
||||
|
||||
@@ -221,7 +221,7 @@ export function Chat() {
|
||||
active: isLatestOpenRun,
|
||||
agentLabel: segmentAgentLabel,
|
||||
sessionLabel: segmentSessionLabel,
|
||||
segmentEnd: replyIndex ?? (nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1),
|
||||
segmentEnd: nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1,
|
||||
steps,
|
||||
}];
|
||||
});
|
||||
@@ -257,6 +257,7 @@ export function Chat() {
|
||||
message={msg}
|
||||
showThinking={showThinking}
|
||||
suppressToolCards={suppressToolCards}
|
||||
suppressProcessAttachments={suppressToolCards}
|
||||
/>
|
||||
{userRunCards
|
||||
.filter((card) => card.triggerIndex === idx)
|
||||
|
||||
@@ -166,23 +166,64 @@ export function deriveTaskSteps({
|
||||
showThinking,
|
||||
}: DeriveTaskStepsInput): TaskStep[] {
|
||||
const steps: TaskStep[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const activeToolNames = new Set<string>();
|
||||
const stepIndexById = new Map<string, number>();
|
||||
|
||||
const pushStep = (step: TaskStep): void => {
|
||||
if (seenIds.has(step.id)) return;
|
||||
seenIds.add(step.id);
|
||||
steps.push(step);
|
||||
const upsertStep = (step: TaskStep): void => {
|
||||
const existingIndex = stepIndexById.get(step.id);
|
||||
if (existingIndex == null) {
|
||||
stepIndexById.set(step.id, steps.length);
|
||||
steps.push(step);
|
||||
return;
|
||||
}
|
||||
const existing = steps[existingIndex];
|
||||
steps[existingIndex] = {
|
||||
...existing,
|
||||
...step,
|
||||
detail: step.detail ?? existing.detail,
|
||||
};
|
||||
};
|
||||
|
||||
const streamMessage = streamingMessage && typeof streamingMessage === 'object'
|
||||
? streamingMessage as RawMessage
|
||||
: null;
|
||||
|
||||
const relevantAssistantMessages = messages.filter((message) => {
|
||||
if (!message || message.role !== 'assistant') return false;
|
||||
if (extractToolUse(message).length > 0) return true;
|
||||
return showThinking && !!extractThinking(message);
|
||||
});
|
||||
|
||||
for (const [messageIndex, assistantMessage] of relevantAssistantMessages.entries()) {
|
||||
if (showThinking) {
|
||||
const thinking = extractThinking(assistantMessage);
|
||||
if (thinking) {
|
||||
upsertStep({
|
||||
id: `history-thinking-${assistantMessage.id || messageIndex}`,
|
||||
label: 'Thinking',
|
||||
status: 'completed',
|
||||
kind: 'thinking',
|
||||
detail: normalizeText(thinking),
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extractToolUse(assistantMessage).forEach((tool, index) => {
|
||||
upsertStep({
|
||||
id: tool.id || makeToolId(`history-tool-${assistantMessage.id || messageIndex}`, tool.name, index),
|
||||
label: tool.name,
|
||||
status: 'completed',
|
||||
kind: 'tool',
|
||||
detail: normalizeText(JSON.stringify(tool.input, null, 2)),
|
||||
depth: 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (streamMessage && showThinking) {
|
||||
const thinking = extractThinking(streamMessage);
|
||||
if (thinking) {
|
||||
pushStep({
|
||||
upsertStep({
|
||||
id: 'stream-thinking',
|
||||
label: 'Thinking',
|
||||
status: 'running',
|
||||
@@ -193,10 +234,16 @@ export function deriveTaskSteps({
|
||||
}
|
||||
}
|
||||
|
||||
const activeToolIds = new Set<string>();
|
||||
const activeToolNamesWithoutIds = new Set<string>();
|
||||
streamingTools.forEach((tool, index) => {
|
||||
activeToolNames.add(tool.name);
|
||||
pushStep({
|
||||
id: tool.toolCallId || tool.id || makeToolId('stream-status', tool.name, index),
|
||||
const id = tool.toolCallId || tool.id || makeToolId('stream-status', tool.name, index);
|
||||
activeToolIds.add(id);
|
||||
if (!tool.toolCallId && !tool.id) {
|
||||
activeToolNamesWithoutIds.add(tool.name);
|
||||
}
|
||||
upsertStep({
|
||||
id,
|
||||
label: tool.name,
|
||||
status: tool.status,
|
||||
kind: 'tool',
|
||||
@@ -207,9 +254,10 @@ export function deriveTaskSteps({
|
||||
|
||||
if (streamMessage) {
|
||||
extractToolUse(streamMessage).forEach((tool, index) => {
|
||||
if (activeToolNames.has(tool.name)) return;
|
||||
pushStep({
|
||||
id: tool.id || makeToolId('stream-tool', tool.name, index),
|
||||
const id = tool.id || makeToolId('stream-tool', tool.name, index);
|
||||
if (activeToolIds.has(id) || activeToolNamesWithoutIds.has(tool.name)) return;
|
||||
upsertStep({
|
||||
id,
|
||||
label: tool.name,
|
||||
status: 'running',
|
||||
kind: 'tool',
|
||||
@@ -220,59 +268,27 @@ export function deriveTaskSteps({
|
||||
}
|
||||
|
||||
if (sending && pendingFinal) {
|
||||
pushStep({
|
||||
id: 'system-finalizing',
|
||||
label: 'Finalizing answer',
|
||||
status: 'running',
|
||||
upsertStep({
|
||||
id: 'system-finalizing',
|
||||
label: 'Finalizing answer',
|
||||
status: 'running',
|
||||
kind: 'system',
|
||||
detail: 'Waiting for the assistant to finish this run.',
|
||||
depth: 1,
|
||||
});
|
||||
} else if (sending && steps.length === 0) {
|
||||
pushStep({
|
||||
id: 'system-preparing',
|
||||
label: 'Preparing run',
|
||||
status: 'running',
|
||||
upsertStep({
|
||||
id: 'system-preparing',
|
||||
label: 'Preparing run',
|
||||
status: 'running',
|
||||
kind: 'system',
|
||||
detail: 'Waiting for the first streaming update.',
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (steps.length === 0) {
|
||||
const relevantAssistantMessages = messages.filter((message) => {
|
||||
if (!message || message.role !== 'assistant') return false;
|
||||
if (extractToolUse(message).length > 0) return true;
|
||||
return showThinking && !!extractThinking(message);
|
||||
});
|
||||
|
||||
for (const [messageIndex, assistantMessage] of relevantAssistantMessages.entries()) {
|
||||
if (showThinking) {
|
||||
const thinking = extractThinking(assistantMessage);
|
||||
if (thinking) {
|
||||
pushStep({
|
||||
id: `history-thinking-${assistantMessage.id || messageIndex}`,
|
||||
label: 'Thinking',
|
||||
status: 'completed',
|
||||
kind: 'thinking',
|
||||
detail: normalizeText(thinking),
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extractToolUse(assistantMessage).forEach((tool, index) => {
|
||||
pushStep({
|
||||
id: tool.id || makeToolId(`history-tool-${assistantMessage.id || messageIndex}`, tool.name, index),
|
||||
label: tool.name,
|
||||
status: 'completed',
|
||||
kind: 'tool',
|
||||
detail: normalizeText(JSON.stringify(tool.input, null, 2)),
|
||||
depth: 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return attachTopology(steps).slice(0, MAX_TASK_STEPS);
|
||||
const withTopology = attachTopology(steps);
|
||||
return withTopology.length > MAX_TASK_STEPS
|
||||
? withTopology.slice(-MAX_TASK_STEPS)
|
||||
: withTopology;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,13 @@ function upsertImageCacheEntry(filePath: string, file: Omit<AttachedFileMeta, 'f
|
||||
saveImageCache(_imageCache);
|
||||
}
|
||||
|
||||
function withAttachedFileSource(
|
||||
file: AttachedFileMeta,
|
||||
source: AttachedFileMeta['source'],
|
||||
): AttachedFileMeta {
|
||||
return file.source ? file : { ...file, source };
|
||||
}
|
||||
|
||||
/** Extract plain text from message content (string or content blocks) */
|
||||
function getMessageText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
@@ -228,11 +235,14 @@ function extractImagesAsAttachedFiles(content: unknown): AttachedFileMeta[] {
|
||||
/**
|
||||
* Build an AttachedFileMeta entry for a file ref, using cache if available.
|
||||
*/
|
||||
function makeAttachedFile(ref: { filePath: string; mimeType: string }): AttachedFileMeta {
|
||||
function makeAttachedFile(
|
||||
ref: { filePath: string; mimeType: string },
|
||||
source: AttachedFileMeta['source'] = 'message-ref',
|
||||
): AttachedFileMeta {
|
||||
const cached = _imageCache.get(ref.filePath);
|
||||
if (cached) return { ...cached, filePath: ref.filePath };
|
||||
if (cached) return { ...cached, filePath: ref.filePath, source };
|
||||
const fileName = ref.filePath.split(/[\\/]/).pop() || 'file';
|
||||
return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath };
|
||||
return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath, source };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,7 +355,7 @@ function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
pending.push(...imageFiles);
|
||||
pending.push(...imageFiles.map((file) => withAttachedFileSource(file, 'tool-result')));
|
||||
|
||||
// 2. [media attached: ...] patterns in tool result text output
|
||||
const text = getMessageText(msg.content);
|
||||
@@ -353,12 +363,12 @@ function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] {
|
||||
const mediaRefs = extractMediaRefs(text);
|
||||
const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath));
|
||||
for (const ref of mediaRefs) {
|
||||
pending.push(makeAttachedFile(ref));
|
||||
pending.push(makeAttachedFile(ref, 'tool-result'));
|
||||
}
|
||||
// 3. Raw file paths in tool result text (documents, audio, video, etc.)
|
||||
for (const ref of extractRawFilePaths(text)) {
|
||||
if (!mediaRefPaths.has(ref.filePath)) {
|
||||
pending.push(makeAttachedFile(ref));
|
||||
pending.push(makeAttachedFile(ref, 'tool-result'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,9 +445,9 @@ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] {
|
||||
|
||||
const files: AttachedFileMeta[] = allRefs.map(ref => {
|
||||
const cached = _imageCache.get(ref.filePath);
|
||||
if (cached) return { ...cached, filePath: ref.filePath };
|
||||
if (cached) return { ...cached, filePath: ref.filePath, source: 'message-ref' };
|
||||
const fileName = ref.filePath.split(/[\\/]/).pop() || 'file';
|
||||
return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath };
|
||||
return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath, source: 'message-ref' };
|
||||
});
|
||||
return { ...msg, _attachedFiles: files };
|
||||
});
|
||||
|
||||
@@ -86,9 +86,8 @@ export function handleRuntimeEventState(
|
||||
: undefined;
|
||||
|
||||
// Mirror enrichWithToolResultFiles: collect images + file refs for next assistant msg
|
||||
const toolFiles: AttachedFileMeta[] = [
|
||||
...extractImagesAsAttachedFiles(finalMsg.content),
|
||||
];
|
||||
const toolFiles: AttachedFileMeta[] = extractImagesAsAttachedFiles(finalMsg.content)
|
||||
.map((file) => (file.source ? file : { ...file, source: 'tool-result' }));
|
||||
if (matchedPath) {
|
||||
for (const f of toolFiles) {
|
||||
if (!f.filePath) {
|
||||
@@ -101,9 +100,9 @@ export function handleRuntimeEventState(
|
||||
if (text) {
|
||||
const mediaRefs = extractMediaRefs(text);
|
||||
const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath));
|
||||
for (const ref of mediaRefs) toolFiles.push(makeAttachedFile(ref));
|
||||
for (const ref of mediaRefs) toolFiles.push(makeAttachedFile(ref, 'tool-result'));
|
||||
for (const ref of extractRawFilePaths(text)) {
|
||||
if (!mediaRefPaths.has(ref.filePath)) toolFiles.push(makeAttachedFile(ref));
|
||||
if (!mediaRefPaths.has(ref.filePath)) toolFiles.push(makeAttachedFile(ref, 'tool-result'));
|
||||
}
|
||||
}
|
||||
set((s) => {
|
||||
|
||||
@@ -94,6 +94,7 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<Runti
|
||||
fileSize: a.fileSize,
|
||||
preview: a.preview,
|
||||
filePath: a.stagedPath,
|
||||
source: 'user-upload',
|
||||
})),
|
||||
};
|
||||
set((s) => ({
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface AttachedFileMeta {
|
||||
fileSize: number;
|
||||
preview: string | null;
|
||||
filePath?: string;
|
||||
source?: 'user-upload' | 'tool-result' | 'message-ref';
|
||||
}
|
||||
|
||||
/** Raw message from OpenClaw chat.history */
|
||||
|
||||
Reference in New Issue
Block a user