diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index c5213f6ebc..7180b026fe 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -2,10 +2,11 @@ import "./ChatMessage.css"; import { Marked } from "marked"; import { useMemo } from "preact/hooks"; + import { t } from "../../../services/i18n.js"; import utils from "../../../services/utils.js"; import { SanitizedHtml } from "../../react/RawHtml.js"; -import { type ContentBlock, getMessageText, type StoredMessage } from "./llm_chat_types.js"; +import { type ContentBlock, getMessageText, type StoredMessage, type TextBlock, type ToolCallBlock } from "./llm_chat_types.js"; import ToolCallCard from "./ToolCallCard.js"; function shortenNumber(n: number): string { @@ -30,23 +31,9 @@ interface Props { isStreaming?: boolean; } -function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) { - return blocks.map((block, idx) => { - if (block.type === "text") { - const html = renderMarkdown(block.content); - return ( -
- - {isStreaming && idx === blocks.length - 1 && } -
- ); - } - if (block.type === "tool_call") { - return ; - } - return null; - }); -} +type ContentGroup = + | { type: "text"; block: TextBlock; index: number } + | { type: "tool_calls"; blocks: ToolCallBlock[]; index: number }; export default function ChatMessage({ message, isStreaming }: Props) { const roleLabel = message.role === "user" ? t("llm_chat.role_user") : t("llm_chat.role_assistant"); @@ -189,3 +176,40 @@ export default function ChatMessage({ message, isStreaming }: Props) { ); } + +/** Group content blocks so that consecutive tool_calls are merged into one entry. */ +function groupContentBlocks(blocks: ContentBlock[]): ContentGroup[] { + const groups: ContentGroup[] = []; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + if (block.type === "tool_call") { + const last = groups[groups.length - 1]; + if (last?.type === "tool_calls") { + last.blocks.push(block); + } else { + groups.push({ type: "tool_calls", blocks: [block], index: i }); + } + } else { + groups.push({ type: "text", block, index: i }); + } + } + + return groups; +} + +function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) { + return groupContentBlocks(blocks).map((group) => { + if (group.type === "text") { + const html = renderMarkdown(group.block.content); + return ( +
+ + {isStreaming && group.index === blocks.length - 1 && } +
+ ); + } + + return b.toolCall)} />; + }); +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css b/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css index a07a5526f3..4d83d61103 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css +++ b/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.css @@ -1,21 +1,17 @@ -.llm-chat-tool-call-input strong, -.llm-chat-tool-call-result strong { - display: block; - font-size: 0.75rem; - color: var(--muted-text-color); - margin-bottom: 0.25rem; - text-transform: uppercase; -} - -/* Inline tool call cards */ -.llm-chat-tool-call-inline { +/* Tool call card — groups sequential tool calls */ +.llm-chat-tool-call-card { margin: 0.5rem 0; border: 1px solid var(--main-border-color); border-radius: 8px; font-size: 0.85rem; } -.llm-chat-tool-call-inline-summary { +/* Tool call section — individual tool call within a card */ +.llm-chat-tool-call-section + .llm-chat-tool-call-section { + border-top: 1px solid var(--main-border-color); +} + +.llm-chat-tool-call-section-summary { display: flex; flex-wrap: wrap; align-items: baseline; @@ -27,20 +23,20 @@ color: var(--muted-text-color); } -.llm-chat-tool-call-inline-summary::-webkit-details-marker { +.llm-chat-tool-call-section-summary::-webkit-details-marker { display: none; } -.llm-chat-tool-call-inline-summary .llm-chat-tool-call-chevron { +.llm-chat-tool-call-section-summary .llm-chat-tool-call-chevron { margin-left: auto; transition: transform 0.2s ease; } -.llm-chat-tool-call-inline[open] .llm-chat-tool-call-chevron { +.llm-chat-tool-call-section[open] .llm-chat-tool-call-chevron { transform: rotate(180deg); } -.llm-chat-tool-call-inline-summary > .bx { +.llm-chat-tool-call-section-summary > .bx { font-size: 1rem; margin-right: 0.15rem; } @@ -58,22 +54,23 @@ color: var(--muted-text-color); } -.llm-chat-tool-call-inline-body { +/* Section body (input + result) */ +.llm-chat-tool-call-section-body { padding: 0; } -.llm-chat-tool-call-inline-body .llm-chat-tool-call-input, -.llm-chat-tool-call-inline-body .llm-chat-tool-call-result { +.llm-chat-tool-call-section-body .llm-chat-tool-call-input, +.llm-chat-tool-call-section-body .llm-chat-tool-call-result { padding: 0.5rem 0.75rem; max-height: 300px; overflow: auto; } -.llm-chat-tool-call-inline-body .llm-chat-tool-call-result { +.llm-chat-tool-call-section-body .llm-chat-tool-call-result { border-top: 1px solid var(--main-border-color); } -.llm-chat-tool-call-inline-body pre { +.llm-chat-tool-call-section-body pre { margin: 0; padding: 0.5rem; background: var(--main-background-color); @@ -82,11 +79,13 @@ font-family: var(--monospace-font-family, monospace); } -.llm-chat-tool-call-inline-body strong { +.llm-chat-tool-call-input strong, +.llm-chat-tool-call-result strong { display: block; font-size: 0.75rem; color: var(--muted-text-color); margin-bottom: 0.25rem; + text-transform: uppercase; } /* Tool call key-value table */ @@ -146,11 +145,7 @@ } /* Tool call error styling */ -.llm-chat-tool-call-error { - border-color: var(--danger-color, #dc3545); -} - -.llm-chat-tool-call-error .llm-chat-tool-call-inline-summary { +.llm-chat-tool-call-error .llm-chat-tool-call-section-summary { color: var(--danger-color, #dc3545); } diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx index a0a46aa78f..9e6480b09f 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ToolCallCard.tsx @@ -137,16 +137,14 @@ function KeyValueTable({ data, className, depth = 0 }: { data: unknown; classNam ); } -export default function ToolCallCard({ toolCall }: { toolCall: ToolCall }) { - const classes = [ - "llm-chat-tool-call-inline", - toolCall.isError && "llm-chat-tool-call-error" - ].filter(Boolean).join(" "); +/** A single tool call section within a ToolCallCard. */ +function ToolCallSection({ toolCall }: { toolCall: ToolCall }) { const { noteId: refNoteId, parentNoteId: refParentId, detailText } = getToolCallContext(toolCall); + const hasError = toolCall.isError; return ( -
- +
+ {t(`llm.tools.${toolCall.toolName}`, { defaultValue: toolCall.toolName })} {detailText && ( @@ -167,17 +165,17 @@ export default function ToolCallCard({ toolCall }: { toolCall: ToolCall }) { )} )} - {toolCall.isError && {t("llm_chat.tool_error")}} + {hasError && {t("llm_chat.tool_error")}} -
+
{t("llm_chat.input")}
{toolCall.result && ( -
- {toolCall.isError ? t("llm_chat.error") : t("llm_chat.result")} +
+ {hasError ? t("llm_chat.error") : t("llm_chat.result")}
)} @@ -185,3 +183,14 @@ export default function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
); } + +/** A card that groups one or more sequential tool calls together. */ +export default function ToolCallCard({ toolCalls }: { toolCalls: ToolCall[] }) { + return ( +
+ {toolCalls.map((tc, idx) => ( + + ))} +
+ ); +}