diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts index b2e4efc947..254ff069e1 100644 --- a/apps/client/src/services/llm_chat.ts +++ b/apps/client/src/services/llm_chat.ts @@ -22,6 +22,7 @@ export interface Citation { export interface StreamCallbacks { onChunk: (text: string) => void; + onThinking?: (text: string) => void; onToolUse?: (toolName: string, input: Record) => 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; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 75be7d3828..5f03de87ab 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -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", 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 b583c4a3b3..ea986532bb 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -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 ( +
+ + + {t("llm_chat.thought_process")} + +
+ {message.content} + {isStreaming && } +
+
+ ); + } + return (
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 a4fcb771ea..51ff8f1382 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css @@ -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; diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx index 6285d66650..c098499891 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx @@ -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(null); const [pendingCitations, setPendingCitations] = useState([]); 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 => ( ))} - {toolActivity && ( + {toolActivity && !streamingThinking && (
{toolActivity}
)} + {isStreaming && streamingThinking && ( + + )} {isStreaming && streamingContent && ( } | { type: "tool_result"; toolName: string; result: string } | { type: "citation"; url: string; title?: string }