Files
llama.cpp/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
T
nachobh 85d5703a3b ui : fix MCP image attachments not displayed in tool block (#25789) (#28089)
* 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 \)
2026-09-04 19:53:16 +02:00

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>