mirror of
https://github.com/zadam/trilium.git
synced 2026-05-25 07:19:59 +02:00
feat(llm): group tool calls
This commit is contained in:
@@ -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)} />;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user