mirror of
https://github.com/zadam/trilium.git
synced 2026-06-27 16:58:13 +02:00
feat(llm): render tools inline
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user