* ui : fix MCP image attachments not displayed in tool block (#25789) Fixes regression from #25450 where ChatMessageAgenticContent passed message.extra instead of section.toolResultExtras to tool blocks, leaving tool images invisible. Also fixes TOOL_RESULT_JSON_OPEN_REGEX which misclassified "[Attachment saved: ...]" as JSON. Fixes #25789 Assisted-by: Muse Spark * Addressed PR comments: 1.- Removed ·?? mesage?extra· as it has no case left to cover 2.- Added ·[\· to cover the case of ·[[1, 2], [3, 4]]· case suggested in the PR comment 3.- Added unit test for covering up this regex case * ui : fix MCP image attachments not displayed in tool block (ggml-org#25789) - Addressed lint error on regex (redundant \)
275 lines
7.5 KiB
Svelte
275 lines
7.5 KiB
Svelte
<script lang="ts">
|
|
import ChatMessageToolCallBlock from './ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte';
|
|
import ChatMessageReasoningBlock from './ChatMessageReasoningBlock.svelte';
|
|
import {
|
|
ChatMessageActionCardContinueRequest,
|
|
ChatMessageActionCardPermissionRequest,
|
|
ChatMessageStatistics,
|
|
MarkdownContent
|
|
} from '$lib/components/app';
|
|
import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums';
|
|
import { agenticStore, settingsStore } from '$lib/stores';
|
|
import type {
|
|
AgenticSection,
|
|
ChatMessageAgenticTimings,
|
|
ChatMessageAgenticTurnStats,
|
|
DatabaseMessage
|
|
} from '$lib/types';
|
|
import { deriveAgenticSections } from '$lib/utils';
|
|
|
|
interface Props {
|
|
message: DatabaseMessage;
|
|
toolMessages?: DatabaseMessage[];
|
|
isStreaming?: boolean;
|
|
isLastAssistantMessage?: boolean;
|
|
}
|
|
|
|
let {
|
|
isLastAssistantMessage = false,
|
|
isStreaming = false,
|
|
message,
|
|
toolMessages = []
|
|
}: Props = $props();
|
|
|
|
let expandedStates: Record<number, boolean> = $state({});
|
|
|
|
const showThoughtInProgress = $derived(Boolean(settingsStore.config.showThoughtInProgress));
|
|
const alwaysShowToolCallContent = $derived(
|
|
Boolean(settingsStore.config.alwaysShowToolCallContent)
|
|
);
|
|
const showMessageStats = $derived(Boolean(settingsStore.config.showMessageStats));
|
|
const showAgenticTurnStats = $derived(
|
|
showMessageStats && Boolean(settingsStore.config.showAgenticTurnStats)
|
|
);
|
|
|
|
const hasReasoningError = $derived(
|
|
isLastAssistantMessage ? !!agenticStore.getLastError(message.convId) : false
|
|
);
|
|
|
|
let permissionDismissed = $state(false);
|
|
|
|
const pendingPermission = $derived(
|
|
isStreaming && isLastAssistantMessage
|
|
? agenticStore.getPendingPermissionRequest(message.convId)
|
|
: null
|
|
);
|
|
|
|
let prevPendingRef: typeof pendingPermission = null;
|
|
$effect(() => {
|
|
if (pendingPermission !== prevPendingRef) {
|
|
prevPendingRef = pendingPermission;
|
|
|
|
if (pendingPermission) {
|
|
permissionDismissed = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
function handlePermission(decision: ToolPermissionDecision) {
|
|
permissionDismissed = true;
|
|
agenticStore.resolvePermission(message.convId, decision);
|
|
}
|
|
|
|
let continueDismissed = $state(false);
|
|
|
|
const pendingContinue = $derived(
|
|
isStreaming && isLastAssistantMessage
|
|
? agenticStore.getPendingContinueRequest(message.convId)
|
|
: false
|
|
);
|
|
|
|
let prevContinueRef = false;
|
|
$effect(() => {
|
|
if (pendingContinue !== prevContinueRef) {
|
|
prevContinueRef = pendingContinue;
|
|
|
|
if (pendingContinue) {
|
|
continueDismissed = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
function handleContinue(shouldContinue: boolean) {
|
|
continueDismissed = true;
|
|
agenticStore.resolveContinue(message.convId, shouldContinue);
|
|
}
|
|
|
|
const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
|
|
|
|
const currentlyExecutingToolCallId = $derived(
|
|
isStreaming ? agenticStore.getExecutingToolCallId(message.convId) : null
|
|
);
|
|
|
|
type TurnGroup = {
|
|
sections: AgenticSection[];
|
|
flatIndices: number[];
|
|
};
|
|
|
|
const turnGroups: TurnGroup[] = $derived.by(() => {
|
|
const groups: TurnGroup[] = [];
|
|
|
|
let currentTurn: AgenticSection[] = [];
|
|
let currentIndices: number[] = [];
|
|
let prevWasTool = false;
|
|
|
|
for (let i = 0; i < sections.length; i++) {
|
|
const section = sections[i];
|
|
const isTool =
|
|
section.type === AgenticSectionType.TOOL_CALL ||
|
|
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
|
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
|
|
|
|
if (!isTool && prevWasTool && currentTurn.length > 0) {
|
|
groups.push({ flatIndices: currentIndices, sections: currentTurn });
|
|
currentTurn = [];
|
|
currentIndices = [];
|
|
}
|
|
|
|
currentTurn.push(section);
|
|
currentIndices.push(i);
|
|
prevWasTool = isTool;
|
|
}
|
|
|
|
if (currentTurn.length > 0) {
|
|
groups.push({ flatIndices: currentIndices, sections: currentTurn });
|
|
}
|
|
|
|
return groups;
|
|
});
|
|
|
|
function getDefaultExpanded(section: AgenticSection): boolean {
|
|
if (
|
|
section.type === AgenticSectionType.TOOL_CALL ||
|
|
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
|
section.type === AgenticSectionType.TOOL_CALL_STREAMING
|
|
) {
|
|
return alwaysShowToolCallContent;
|
|
}
|
|
|
|
if (section.type === AgenticSectionType.REASONING_PENDING) {
|
|
return showThoughtInProgress;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isExpanded(index: number, section: AgenticSection): boolean {
|
|
if (expandedStates[index] !== undefined) {
|
|
return expandedStates[index];
|
|
}
|
|
|
|
return getDefaultExpanded(section);
|
|
}
|
|
|
|
function toggleExpanded(index: number, section: AgenticSection) {
|
|
const currentState = isExpanded(index, section);
|
|
|
|
expandedStates[index] = !currentState;
|
|
}
|
|
|
|
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
|
|
return {
|
|
llm: stats.llm,
|
|
toolCalls: stats.toolCalls,
|
|
toolCallsCount: stats.toolCalls.length,
|
|
toolsMs: stats.toolsMs,
|
|
turns: 1
|
|
};
|
|
}
|
|
</script>
|
|
|
|
{#snippet renderSection(section: AgenticSection, index: number)}
|
|
{#if section.type === AgenticSectionType.TEXT}
|
|
<div class="agentic-text">
|
|
<MarkdownContent attachments={message?.extra} content={section.content} />
|
|
</div>
|
|
{:else if section.type === AgenticSectionType.REASONING || section.type === AgenticSectionType.REASONING_PENDING}
|
|
<ChatMessageReasoningBlock
|
|
attachments={message?.extra}
|
|
{hasReasoningError}
|
|
{isStreaming}
|
|
onToggle={() => toggleExpanded(index, section)}
|
|
open={isExpanded(index, section)}
|
|
{section}
|
|
/>
|
|
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING}
|
|
<ChatMessageToolCallBlock
|
|
attachments={section.toolResultExtras}
|
|
isExecuting={section.toolCallId !== undefined &&
|
|
section.toolCallId === currentlyExecutingToolCallId}
|
|
{isStreaming}
|
|
onToggle={() => toggleExpanded(index, section)}
|
|
open={isExpanded(index, section)}
|
|
{section}
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<div class="agentic-content gap-2">
|
|
{#if turnGroups.length > 1}
|
|
{#each turnGroups as turn, turnIndex (turnIndex)}
|
|
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
|
|
|
|
<div class="agentic-turn group/turn grid gap-2">
|
|
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
|
|
{@render renderSection(section, turn.flatIndices[sIdx])}
|
|
{/each}
|
|
|
|
{#if turnStats && showAgenticTurnStats}
|
|
<div class="turn-stats transition-opacity duration-150 mt-1 mb-4">
|
|
<ChatMessageStatistics
|
|
agenticTimings={turnStats.toolCalls.length > 0
|
|
? buildTurnAgenticTimings(turnStats)
|
|
: undefined}
|
|
hideSummary
|
|
initialView={ChatMessageStatsView.GENERATION}
|
|
predictedMs={turnStats.llm.predicted_ms}
|
|
predictedTokens={turnStats.llm.predicted_n}
|
|
promptMs={turnStats.llm.prompt_ms}
|
|
promptTokens={turnStats.llm.prompt_n}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
{#each sections as section, index (index)}
|
|
{@render renderSection(section, index)}
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if pendingPermission && !permissionDismissed}
|
|
<ChatMessageActionCardPermissionRequest
|
|
onDecision={handlePermission}
|
|
serverLabel={pendingPermission.serverLabel}
|
|
toolName={pendingPermission.toolName}
|
|
/>
|
|
{/if}
|
|
|
|
{#if pendingContinue && !continueDismissed}
|
|
<ChatMessageActionCardContinueRequest onDecision={handleContinue} />
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.agentic-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
max-width: 48rem;
|
|
}
|
|
|
|
.agentic-content > :global(*),
|
|
.agentic-turn > :global(*) {
|
|
min-width: 0;
|
|
}
|
|
|
|
.agentic-text {
|
|
width: 100%;
|
|
}
|
|
|
|
.turn-stats {
|
|
border-top: 1px solid hsl(var(--muted) / 0.5);
|
|
}
|
|
</style>
|