From 8a492450daf9935b3dabb088c7d2bd2919ff2e94 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 30 Mar 2026 17:29:25 +0300 Subject: [PATCH] feat(llm): render tools inline --- .../type_widgets/llm_chat/ChatMessage.tsx | 140 ++++++++++-------- .../widgets/type_widgets/llm_chat/LlmChat.css | 66 +++++++++ .../type_widgets/llm_chat/llm_chat_types.ts | 50 ++++++- .../type_widgets/llm_chat/useLlmChat.ts | 54 +++++-- 4 files changed, 231 insertions(+), 79 deletions(-) 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 32971a7547..f1ae65b58e 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -2,6 +2,8 @@ import type { LlmCitation, LlmUsage } from "@triliumnext/commons"; import { useMemo } from "preact/hooks"; import { marked } from "marked"; import { t } from "../../../services/i18n.js"; +import type { ContentBlock, StoredMessage, ToolCall } from "./llm_chat_types.js"; +import { getMessageText, getMessageToolCalls } from "./llm_chat_types.js"; import "./LlmChat.css"; // Configure marked for safe rendering @@ -10,42 +12,73 @@ marked.setOptions({ gfm: true // GitHub Flavored Markdown }); -type MessageType = "message" | "error" | "thinking"; - -interface ToolCall { - id: string; - toolName: string; - input: Record; - result?: string; -} - -interface StoredMessage { - id: string; - role: "user" | "assistant" | "system"; - content: string; - createdAt: string; - citations?: LlmCitation[]; - /** Message type for special rendering. Defaults to "message" if omitted. */ - type?: MessageType; - /** Tool calls made during this response */ - toolCalls?: ToolCall[]; - /** Token usage for this response */ - usage?: LlmUsage; -} - interface Props { message: StoredMessage; isStreaming?: boolean; } +function ToolCallCard({ toolCall }: { toolCall: ToolCall }) { + return ( +
+ + + {toolCall.toolName} + +
+
+ {t("llm_chat.input")}: +
{JSON.stringify(toolCall.input, null, 2)}
+
+ {toolCall.result && ( +
+ {t("llm_chat.result")}: +
{(() => {
+                            if (typeof toolCall.result === "string" && (toolCall.result.startsWith("{") || toolCall.result.startsWith("["))) {
+                                try {
+                                    return JSON.stringify(JSON.parse(toolCall.result), null, 2);
+                                } catch {
+                                    return toolCall.result;
+                                }
+                            }
+                            return toolCall.result;
+                        })()}
+
+ )} +
+
+ ); +} + +function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) { + return blocks.map((block, idx) => { + if (block.type === "text") { + const html = marked.parse(block.content) as string; + return ( +
+
+ {isStreaming && idx === blocks.length - 1 && } +
+ ); + } + if (block.type === "tool_call") { + return ; + } + return null; + }); +} + export default function ChatMessage({ message, isStreaming }: Props) { const roleLabel = message.role === "user" ? "You" : "Assistant"; const isError = message.type === "error"; const isThinking = message.type === "thinking"; + const textContent = typeof message.content === "string" ? message.content : getMessageText(message.content); - // Render markdown for assistant messages (not errors or thinking) + // Render markdown for assistant messages with legacy string content const renderedContent = useMemo(() => { - if (message.role === "assistant" && !isError && !isThinking) { + if (message.role === "assistant" && !isError && !isThinking && typeof message.content === "string") { return marked.parse(message.content) as string; } return null; @@ -67,13 +100,17 @@ export default function ChatMessage({ message, isStreaming }: Props) { {t("llm_chat.thought_process")}
- {message.content} + {textContent} {isStreaming && }
); } + // Legacy tool calls (from old format stored as separate field) + const legacyToolCalls = message.toolCalls; + const hasBlockContent = Array.isArray(message.content); + return (
@@ -81,49 +118,30 @@ export default function ChatMessage({ message, isStreaming }: Props) {
{message.role === "assistant" && !isError ? ( - <> -
- {isStreaming && } - + hasBlockContent ? ( + renderContentBlocks(message.content as ContentBlock[], isStreaming) + ) : ( + <> +
+ {isStreaming && } + + ) ) : ( - message.content + textContent )}
- {message.toolCalls && message.toolCalls.length > 0 && ( + {legacyToolCalls && legacyToolCalls.length > 0 && (
- {t("llm_chat.tool_calls", { count: message.toolCalls.length })} + {t("llm_chat.tool_calls", { count: legacyToolCalls.length })}
- {message.toolCalls.map((tool) => ( -
-
- {tool.toolName} -
-
- {t("llm_chat.input")}: -
{JSON.stringify(tool.input, null, 2)}
-
- {tool.result && ( -
- {t("llm_chat.result")}: -
{(() => {
-                                            if (typeof tool.result === "string" && (tool.result.startsWith("{") || tool.result.startsWith("["))) {
-                                                try {
-                                                    return JSON.stringify(JSON.parse(tool.result), null, 2);
-                                                } catch {
-                                                    return tool.result;
-                                                }
-                                            }
-                                            return tool.result;
-                                        })()}
-
- )} -
+ {legacyToolCalls.map((tool) => ( + ))}
diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css index dc093ccd4f..0ea1d28117 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css @@ -522,6 +522,72 @@ overflow-y: auto; } +/* Inline tool call cards (timeline style) */ +.llm-chat-tool-call-inline { + margin: 0.5rem 0; + background: var(--accented-background-color); + border-radius: 6px; + border-left: 3px solid var(--muted-text-color); + font-size: 0.85rem; +} + +.llm-chat-tool-call-inline-summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + list-style: none; + font-weight: 500; + color: var(--muted-text-color); + font-family: var(--monospace-font-family, monospace); +} + +.llm-chat-tool-call-inline-summary::-webkit-details-marker { + display: none; +} + +.llm-chat-tool-call-inline-summary::before { + content: "▶"; + font-size: 0.7em; + transition: transform 0.2s ease; +} + +.llm-chat-tool-call-inline[open] .llm-chat-tool-call-inline-summary::before { + transform: rotate(90deg); +} + +.llm-chat-tool-call-inline-summary .bx { + font-size: 1rem; +} + +.llm-chat-tool-call-inline-body { + padding: 0 0.75rem 0.75rem; +} + +.llm-chat-tool-call-inline-body pre { + margin: 0; + padding: 0.5rem; + background: var(--main-background-color); + border-radius: 4px; + overflow-x: auto; + font-size: 0.8rem; + font-family: var(--monospace-font-family, monospace); + max-height: 200px; + overflow-y: auto; +} + +.llm-chat-tool-call-inline-body strong { + display: block; + font-size: 0.75rem; + color: var(--muted-text-color); + margin-bottom: 0.25rem; +} + +.llm-chat-tool-call-inline-body .llm-chat-tool-call-result { + margin-top: 0.5rem; +} + /* Token usage display */ .llm-chat-usage { display: flex; diff --git a/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts b/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts index 883f0d1331..c5bba57fc1 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/llm_chat_types.ts @@ -9,15 +9,61 @@ export interface ToolCall { result?: string; } +/** A block of text content (rendered as Markdown for assistant messages). */ +export interface TextBlock { + type: "text"; + content: string; +} + +/** A tool invocation block shown inline in the message timeline. */ +export interface ToolCallBlock { + type: "tool_call"; + toolCall: ToolCall; +} + +/** An ordered content block in an assistant message. */ +export type ContentBlock = TextBlock | ToolCallBlock; + +/** + * Extract the plain text from message content (works for both legacy string and block formats). + */ +export function getMessageText(content: string | ContentBlock[]): string { + if (typeof content === "string") { + return content; + } + return content + .filter((b): b is TextBlock => b.type === "text") + .map(b => b.content) + .join(""); +} + +/** + * Extract tool calls from message content blocks. + */ +export function getMessageToolCalls(message: StoredMessage): ToolCall[] { + // Legacy format: tool calls stored in separate field + if (message.toolCalls) { + return message.toolCalls; + } + // Block format: extract from content blocks + if (Array.isArray(message.content)) { + return message.content + .filter((b): b is ToolCallBlock => b.type === "tool_call") + .map(b => b.toolCall); + } + return []; +} + export interface StoredMessage { id: string; role: "user" | "assistant" | "system"; - content: string; + /** Message content: plain string (user messages, legacy) or ordered content blocks (assistant). */ + content: string | ContentBlock[]; createdAt: string; citations?: LlmCitation[]; /** Message type for special rendering. Defaults to "message" if omitted. */ type?: MessageType; - /** Tool calls made during this response */ + /** @deprecated Tool calls are now inline in content blocks. Kept for backward compatibility. */ toolCalls?: ToolCall[]; /** Token usage for this response */ usage?: LlmUsage; diff --git a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts index a15826567e..6c5619a978 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { t } from "../../../services/i18n.js"; import { getAvailableModels, streamChatCompletion } from "../../../services/llm_chat.js"; import { randomString } from "../../../services/utils.js"; -import type { LlmChatContent, StoredMessage, ToolCall } from "./llm_chat_types.js"; +import type { ContentBlock, LlmChatContent, StoredMessage, ToolCall } from "./llm_chat_types.js"; export interface ModelOption extends LlmModelInfo { costDescription?: string; @@ -205,15 +205,28 @@ export function useLlmChat( setStreamingContent(""); setStreamingThinking(""); - let assistantContent = ""; let thinkingContent = ""; + const contentBlocks: ContentBlock[] = []; const citations: LlmCitation[] = []; - const toolCalls: ToolCall[] = []; let usage: LlmUsage | undefined; + /** Get or create the last text block to append streaming text to. */ + function lastTextBlock(): ContentBlock & { type: "text" } { + const last = contentBlocks[contentBlocks.length - 1]; + if (last?.type === "text") { + return last; + } + const block: ContentBlock = { type: "text", content: "" }; + contentBlocks.push(block); + return block as ContentBlock & { type: "text" }; + } + const apiMessages: LlmMessage[] = newMessages.map(m => ({ role: m.role, - content: m.content + content: typeof m.content === "string" ? m.content : m.content + .filter((b): b is ContentBlock & { type: "text" } => b.type === "text") + .map(b => b.content) + .join("") })); const streamOptions: Parameters[1] = { @@ -231,8 +244,11 @@ export function useLlmChat( streamOptions, { onChunk: (text) => { - assistantContent += text; - setStreamingContent(assistantContent); + lastTextBlock().content += text; + setStreamingContent(contentBlocks + .filter((b): b is ContentBlock & { type: "text" } => b.type === "text") + .map(b => b.content) + .join("")); setToolActivity(null); }, onThinking: (text) => { @@ -245,16 +261,23 @@ export function useLlmChat( ? t("llm_chat.searching_web") : `Using ${toolName}...`; setToolActivity(toolLabel); - toolCalls.push({ - id: randomString(), - toolName, - input: toolInput + contentBlocks.push({ + type: "tool_call", + toolCall: { + id: randomString(), + toolName, + input: toolInput + } }); }, onToolResult: (toolName, result) => { - const toolCall = [...toolCalls].reverse().find(tc => tc.toolName === toolName && !tc.result); - if (toolCall) { - toolCall.result = result; + // Find the most recent tool_call block for this tool without a result + for (let i = contentBlocks.length - 1; i >= 0; i--) { + const block = contentBlocks[i]; + if (block.type === "tool_call" && block.toolCall.toolName === toolName && !block.toolCall.result) { + block.toolCall.result = result; + break; + } } }, onCitation: (citation) => { @@ -294,14 +317,13 @@ export function useLlmChat( }); } - if (assistantContent || toolCalls.length > 0) { + if (contentBlocks.length > 0) { finalNewMessages.push({ id: randomString(), role: "assistant", - content: assistantContent, + content: contentBlocks, createdAt: new Date().toISOString(), citations: citations.length > 0 ? citations : undefined, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, usage }); }