feat(llm): group tool calls

This commit is contained in:
Elian Doran
2026-04-03 21:25:16 +03:00
parent 8522151949
commit 21d24b7bea
3 changed files with 84 additions and 56 deletions

View File

@@ -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 (
<div key={idx}>
<SanitizedHtml className="llm-chat-markdown" html={html} />
{isStreaming && idx === blocks.length - 1 && <span className="llm-chat-cursor" />}
</div>
);
}
if (block.type === "tool_call") {
return <ToolCallCard key={idx} toolCall={block.toolCall} />;
}
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) {
</div>
);
}
/** 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 (
<div key={group.index}>
<SanitizedHtml className="llm-chat-markdown" html={html} />
{isStreaming && group.index === blocks.length - 1 && <span className="llm-chat-cursor" />}
</div>
);
}
return <ToolCallCard key={group.index} toolCalls={group.blocks.map((b) => b.toolCall)} />;
});
}

View File

@@ -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);
}

View File

@@ -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 (
<details className={classes}>
<summary className="llm-chat-tool-call-inline-summary">
<details className={`llm-chat-tool-call-section ${hasError ? "llm-chat-tool-call-error" : ""}`}>
<summary className="llm-chat-tool-call-section-summary">
<span className={toolCallIcon(toolCall)} />
{t(`llm.tools.${toolCall.toolName}`, { defaultValue: toolCall.toolName })}
{detailText && (
@@ -167,17 +165,17 @@ export default function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
)}
</span>
)}
{toolCall.isError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
{hasError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
<span className="bx bx-chevron-down llm-chat-tool-call-chevron" />
</summary>
<div className="llm-chat-tool-call-inline-body">
<div className="llm-chat-tool-call-section-body">
<div className="llm-chat-tool-call-input">
<strong>{t("llm_chat.input")}</strong>
<KeyValueTable data={toolCall.input} />
</div>
{toolCall.result && (
<div className={`llm-chat-tool-call-result ${toolCall.isError ? "llm-chat-tool-call-result-error" : ""}`}>
<strong>{toolCall.isError ? t("llm_chat.error") : t("llm_chat.result")}</strong>
<div className={`llm-chat-tool-call-result ${hasError ? "llm-chat-tool-call-result-error" : ""}`}>
<strong>{hasError ? t("llm_chat.error") : t("llm_chat.result")}</strong>
<KeyValueTable data={toolCall.result} />
</div>
)}
@@ -185,3 +183,14 @@ export default function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
</details>
);
}
/** A card that groups one or more sequential tool calls together. */
export default function ToolCallCard({ toolCalls }: { toolCalls: ToolCall[] }) {
return (
<div className="llm-chat-tool-call-card">
{toolCalls.map((tc, idx) => (
<ToolCallSection key={tc.id ?? idx} toolCall={tc} />
))}
</div>
);
}