feat(llm): display thinking process

This commit is contained in:
Elian Doran
2026-03-29 11:51:39 +03:00
parent ad91d360ce
commit c806a99fbc
7 changed files with 126 additions and 12 deletions

View File

@@ -22,6 +22,7 @@ export interface Citation {
export interface StreamCallbacks {
onChunk: (text: string) => void;
onThinking?: (text: string) => void;
onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
onToolResult?: (toolName: string, result: string) => void;
onCitation?: (citation: Citation) => void;
@@ -80,6 +81,9 @@ export async function streamChatCompletion(
case "text":
callbacks.onChunk(data.content);
break;
case "thinking":
callbacks.onThinking?.(data.content);
break;
case "tool_use":
callbacks.onToolUse?.(data.toolName, data.toolInput);
break;

View File

@@ -1619,7 +1619,9 @@
"searching_web": "Searching the web...",
"web_search": "Web search",
"sources": "Sources",
"extended_thinking": "Extended thinking"
"extended_thinking": "Extended thinking",
"thinking": "Thinking...",
"thought_process": "Thought process"
},
"shared_switch": {
"shared": "Shared",

View File

@@ -10,7 +10,7 @@ marked.setOptions({
gfm: true // GitHub Flavored Markdown
});
type MessageType = "message" | "error";
type MessageType = "message" | "error" | "thinking";
interface StoredMessage {
id: string;
@@ -30,21 +30,39 @@ interface Props {
export default function ChatMessage({ message, isStreaming }: Props) {
const roleLabel = message.role === "user" ? "You" : "Assistant";
const isError = message.type === "error";
const isThinking = message.type === "thinking";
// Only render markdown for assistant messages (not errors)
// Render markdown for assistant messages (not errors or thinking)
const renderedContent = useMemo(() => {
if (message.role === "assistant" && !isError) {
if (message.role === "assistant" && !isError && !isThinking) {
return marked.parse(message.content) as string;
}
return null;
}, [message.content, message.role, isError]);
}, [message.content, message.role, isError, isThinking]);
const messageClasses = [
"llm-chat-message",
`llm-chat-message-${message.role}`,
isError && "llm-chat-message-error"
isError && "llm-chat-message-error",
isThinking && "llm-chat-message-thinking"
].filter(Boolean).join(" ");
// Render thinking messages in a collapsible details element
if (isThinking) {
return (
<details className={messageClasses}>
<summary className="llm-chat-thinking-summary">
<span className="bx bx-brain" />
{t("llm_chat.thought_process")}
</summary>
<div className="llm-chat-message-content llm-chat-thinking-content">
{message.content}
{isStreaming && <span className="llm-chat-cursor" />}
</div>
</details>
);
}
return (
<div className={messageClasses}>
<div className="llm-chat-message-role">

View File

@@ -164,6 +164,51 @@
color: var(--danger-text-color, #c00);
}
/* Thinking message (collapsible) */
.llm-chat-message-thinking {
background: var(--accented-background-color);
border: 1px dashed var(--main-border-color);
cursor: pointer;
}
.llm-chat-thinking-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted-text-color);
padding: 0.25rem 0;
list-style: none;
}
.llm-chat-thinking-summary::-webkit-details-marker {
display: none;
}
.llm-chat-thinking-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-message-thinking[open] .llm-chat-thinking-summary::before {
transform: rotate(90deg);
}
.llm-chat-thinking-summary .bx {
font-size: 1rem;
}
.llm-chat-thinking-content {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--main-border-color);
font-size: 0.9rem;
color: var(--muted-text-color);
white-space: pre-wrap;
}
/* Input form */
.llm-chat-input-form {
display: flex;

View File

@@ -6,7 +6,7 @@ import { TypeWidgetProps } from "../type_widget.js";
import ChatMessage from "./ChatMessage.js";
import "./LlmChat.css";
type MessageType = "message" | "error";
type MessageType = "message" | "error" | "thinking";
interface StoredMessage {
id: string;
@@ -30,6 +30,7 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [toolActivity, setToolActivity] = useState<string | null>(null);
const [pendingCitations, setPendingCitations] = useState<Citation[]>([]);
const [enableWebSearch, setEnableWebSearch] = useState(true);
@@ -45,7 +46,7 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, toolActivity, scrollToBottom]);
}, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]);
// Use a ref to store the latest messages for getData
const messagesRef = useRef(messages);
@@ -120,8 +121,10 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
setInput("");
setIsStreaming(true);
setStreamingContent("");
setStreamingThinking("");
let assistantContent = "";
let thinkingContent = "";
const citations: Citation[] = [];
const apiMessages: ChatMessageData[] = newMessages.map(m => ({
@@ -138,6 +141,11 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
setStreamingContent(assistantContent);
setToolActivity(null); // Clear tool activity when text starts
},
onThinking: (text) => {
thinkingContent += text;
setStreamingThinking(thinkingContent);
setToolActivity(t("llm_chat.thinking"));
},
onToolUse: (toolName, _input) => {
const toolLabel = toolName === "web_search"
? t("llm_chat.searching_web")
@@ -160,22 +168,41 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
};
setMessages(prev => [...prev, errorMessage]);
setStreamingContent("");
setStreamingThinking("");
setIsStreaming(false);
setToolActivity(null);
setShouldSave(true);
},
onDone: () => {
const newMessages: StoredMessage[] = [];
// Save thinking as a separate message if present
if (thinkingContent) {
newMessages.push({
id: crypto.randomUUID(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (assistantContent) {
const assistantMessage: StoredMessage = {
newMessages.push({
id: crypto.randomUUID(),
role: "assistant",
content: assistantContent,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined
};
setMessages(prev => [...prev, assistantMessage]);
});
}
if (newMessages.length > 0) {
setMessages(prev => [...prev, ...newMessages]);
}
setStreamingContent("");
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
setToolActivity(null);
@@ -214,12 +241,24 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
{messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{toolActivity && (
{toolActivity && !streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{toolActivity}
</div>
)}
{isStreaming && streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{isStreaming && streamingContent && (
<ChatMessage
message={{

View File

@@ -59,6 +59,7 @@ export class AnthropicProvider implements LlmProvider {
type: "enabled",
budget_tokens: thinkingBudget
};
console.log(`[LLM] Extended thinking enabled with budget: ${thinkingBudget} tokens`);
}
const stream = this.client.messages.stream(streamParams);
@@ -73,11 +74,15 @@ export class AnthropicProvider implements LlmProvider {
toolName: block.name,
toolInput: {} // Input comes in deltas
};
} else if (block.type === "thinking") {
console.log("[LLM] Thinking block started");
}
} else if (event.type === "content_block_delta") {
const delta = event.delta;
if (delta.type === "text_delta") {
yield { type: "text", content: delta.text };
} else if (delta.type === "thinking_delta") {
yield { type: "thinking", content: (delta as any).thinking };
} else if (delta.type === "input_json_delta") {
// Tool input is being streamed - we could accumulate it
// For now, we already emitted tool_use at start

View File

@@ -13,6 +13,7 @@ export interface LlmMessage {
*/
export type LlmStreamChunk =
| { type: "text"; content: string }
| { type: "thinking"; content: string }
| { type: "tool_use"; toolName: string; toolInput: Record<string, unknown> }
| { type: "tool_result"; toolName: string; result: string }
| { type: "citation"; url: string; title?: string }