feat(llm): render tools inline

This commit is contained in:
Elian Doran
2026-03-30 17:29:25 +03:00
parent f3cb356b2b
commit 8a492450da
4 changed files with 231 additions and 79 deletions

View File

@@ -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<string, unknown>;
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 (
<details className="llm-chat-tool-call-inline">
<summary className="llm-chat-tool-call-inline-summary">
<span className="bx bx-wrench" />
{toolCall.toolName}
</summary>
<div className="llm-chat-tool-call-inline-body">
<div className="llm-chat-tool-call-input">
<strong>{t("llm_chat.input")}:</strong>
<pre>{JSON.stringify(toolCall.input, null, 2)}</pre>
</div>
{toolCall.result && (
<div className="llm-chat-tool-call-result">
<strong>{t("llm_chat.result")}:</strong>
<pre>{(() => {
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;
})()}</pre>
</div>
)}
</div>
</details>
);
}
function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) {
return blocks.map((block, idx) => {
if (block.type === "text") {
const html = marked.parse(block.content) as string;
return (
<div key={idx}>
<div
className="llm-chat-markdown"
dangerouslySetInnerHTML={{ __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;
});
}
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")}
</summary>
<div className="llm-chat-message-content llm-chat-thinking-content">
{message.content}
{textContent}
{isStreaming && <span className="llm-chat-cursor" />}
</div>
</details>
);
}
// Legacy tool calls (from old format stored as separate field)
const legacyToolCalls = message.toolCalls;
const hasBlockContent = Array.isArray(message.content);
return (
<div className={messageClasses}>
<div className="llm-chat-message-role">
@@ -81,49 +118,30 @@ export default function ChatMessage({ message, isStreaming }: Props) {
</div>
<div className="llm-chat-message-content">
{message.role === "assistant" && !isError ? (
<>
<div
className="llm-chat-markdown"
dangerouslySetInnerHTML={{ __html: renderedContent || "" }}
/>
{isStreaming && <span className="llm-chat-cursor" />}
</>
hasBlockContent ? (
renderContentBlocks(message.content as ContentBlock[], isStreaming)
) : (
<>
<div
className="llm-chat-markdown"
dangerouslySetInnerHTML={{ __html: renderedContent || "" }}
/>
{isStreaming && <span className="llm-chat-cursor" />}
</>
)
) : (
message.content
textContent
)}
</div>
{message.toolCalls && message.toolCalls.length > 0 && (
{legacyToolCalls && legacyToolCalls.length > 0 && (
<details className="llm-chat-tool-calls">
<summary className="llm-chat-tool-calls-summary">
<span className="bx bx-wrench" />
{t("llm_chat.tool_calls", { count: message.toolCalls.length })}
{t("llm_chat.tool_calls", { count: legacyToolCalls.length })}
</summary>
<div className="llm-chat-tool-calls-list">
{message.toolCalls.map((tool) => (
<div key={tool.id} className="llm-chat-tool-call">
<div className="llm-chat-tool-call-name">
{tool.toolName}
</div>
<div className="llm-chat-tool-call-input">
<strong>{t("llm_chat.input")}:</strong>
<pre>{JSON.stringify(tool.input, null, 2)}</pre>
</div>
{tool.result && (
<div className="llm-chat-tool-call-result">
<strong>{t("llm_chat.result")}:</strong>
<pre>{(() => {
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;
})()}</pre>
</div>
)}
</div>
{legacyToolCalls.map((tool) => (
<ToolCallCard key={tool.id} toolCall={tool} />
))}
</div>
</details>

View File

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

View File

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

View File

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