refactor(llm): deduplicate LLM chat widgets

This commit is contained in:
Elian Doran
2026-03-29 20:28:19 +03:00
parent 2c8e2251fa
commit 23dbedd139
4 changed files with 527 additions and 608 deletions

View File

@@ -1,50 +1,16 @@
import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import dateNoteService from "../../services/date_notes.js";
import { t } from "../../services/i18n.js";
import { getAvailableModels, streamChatCompletion } from "../../services/llm_chat.js";
import server from "../../services/server.js";
import { randomString } from "../../services/utils.js";
import ActionButton from "../react/ActionButton.js";
import NoItems from "../react/NoItems.js";
import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js";
import type { LlmChatContent } from "../type_widgets/llm_chat/llm_chat_types.js";
import { useLlmChat } from "../type_widgets/llm_chat/useLlmChat.js";
import RightPanelWidget from "./RightPanelWidget.js";
import "./SidebarChat.css";
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[];
type?: MessageType;
toolCalls?: ToolCall[];
usage?: LlmUsage;
}
interface LlmChatContent {
version: 1;
messages: StoredMessage[];
selectedModel?: string;
enableWebSearch?: boolean;
enableNoteTools?: boolean;
enableExtendedThinking?: boolean;
}
interface ModelOption extends LlmModelInfo {
costDescription?: string;
}
/**
* Sidebar chat widget that appears in the right panel.
* Uses a hidden LLM chat note for persistence across all notes.
@@ -52,122 +18,35 @@ interface ModelOption extends LlmModelInfo {
*/
export default function SidebarChat() {
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
const [messages, setMessages] = useState<StoredMessage[]>([]);
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<LlmCitation[]>([]);
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [enableWebSearch, setEnableWebSearch] = useState(true);
const [enableNoteTools, setEnableNoteTools] = useState(true); // Default true for sidebar
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [shouldSave, setShouldSave] = useState(false);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Load the most recent chat on mount
const loadMostRecentChat = useCallback(async () => {
try {
const existingChat = await dateNoteService.getMostRecentLlmChat();
// Use shared chat hook with sidebar-specific options
const chat = useLlmChat(
// onMessagesChange - trigger save
() => setShouldSave(true),
{ defaultEnableNoteTools: true, supportsExtendedThinking: false }
);
if (existingChat) {
setChatNoteId(existingChat.noteId);
await loadChatContent(existingChat.noteId);
} else {
// No existing chat - will create on first message
setChatNoteId(null);
setMessages([]);
}
} catch (err) {
console.error("Failed to load sidebar chat:", err);
}
}, []);
// Ref to access chat methods in effects without triggering re-runs
const chatRef = useRef(chat);
chatRef.current = chat;
// Create chat note on demand (when user sends first message)
const ensureChatNoteExists = useCallback(async (): Promise<string | null> => {
if (chatNoteId) {
return chatNoteId;
}
try {
const note = await dateNoteService.getOrCreateLlmChat();
if (note) {
setChatNoteId(note.noteId);
return note.noteId;
}
} catch (err) {
console.error("Failed to create sidebar chat:", err);
}
return null;
}, [chatNoteId]);
// Load the most recent chat on mount
// Handle debounced save when shouldSave is triggered
useEffect(() => {
loadMostRecentChat();
}, [loadMostRecentChat]);
// Fetch available models on mount
useEffect(() => {
getAvailableModels().then(models => {
const modelsWithDescription = models.map(m => ({
...m,
costDescription: m.costMultiplier ? `${m.costMultiplier}x cost` : undefined
}));
setAvailableModels(modelsWithDescription);
if (!selectedModel) {
const defaultModel = models.find(m => m.isDefault) || models[0];
if (defaultModel) {
setSelectedModel(defaultModel.id);
}
}
}).catch(err => {
console.error("Failed to fetch available models:", err);
});
}, []);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]);
const loadChatContent = async (noteId: string) => {
try {
const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`);
if (blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
setMessages(parsed.messages || []);
if (parsed.selectedModel) setSelectedModel(parsed.selectedModel);
if (typeof parsed.enableWebSearch === "boolean") setEnableWebSearch(parsed.enableWebSearch);
if (typeof parsed.enableNoteTools === "boolean") setEnableNoteTools(parsed.enableNoteTools);
}
} catch (err) {
console.error("Failed to load chat content:", err);
if (!shouldSave || !chatNoteId) {
setShouldSave(false);
return;
}
};
const saveChat = useCallback(async (updatedMessages: StoredMessage[]) => {
if (!chatNoteId) return;
setShouldSave(false);
// Clear any pending save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Debounce saves
saveTimeoutRef.current = setTimeout(async () => {
const content: LlmChatContent = {
version: 1,
messages: updatedMessages,
selectedModel: selectedModel || undefined,
enableWebSearch,
enableNoteTools
};
const content = chat.getContent();
try {
await server.put(`notes/${chatNoteId}/data`, {
content: JSON.stringify(content)
@@ -176,142 +55,75 @@ export default function SidebarChat() {
console.error("Failed to save chat:", err);
}
}, 500);
}, [chatNoteId, selectedModel, enableWebSearch, enableNoteTools]);
}, [shouldSave, chatNoteId, chat]);
// Load the most recent chat on mount (runs once)
useEffect(() => {
let cancelled = false;
const loadMostRecentChat = async () => {
try {
const existingChat = await dateNoteService.getMostRecentLlmChat();
if (cancelled) return;
if (existingChat) {
setChatNoteId(existingChat.noteId);
// Load content inline to avoid dependency issues
try {
const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`);
if (!cancelled && blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
chatRef.current.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load chat content:", err);
}
} else {
// No existing chat - will create on first message
setChatNoteId(null);
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to load sidebar chat:", err);
}
};
loadMostRecentChat();
return () => {
cancelled = true;
};
}, []);
// Custom submit handler that ensures chat note exists first
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
if (!chat.input.trim() || chat.isStreaming) return;
// Ensure chat note exists before sending (lazy creation)
const noteId = await ensureChatNoteExists();
let noteId = chatNoteId;
if (!noteId) {
try {
const note = await dateNoteService.getOrCreateLlmChat();
if (note) {
setChatNoteId(note.noteId);
noteId = note.noteId;
}
} catch (err) {
console.error("Failed to create sidebar chat:", err);
return;
}
}
if (!noteId) {
console.error("Cannot send message: no chat note available");
return;
}
setToolActivity(null);
setPendingCitations([]);
const userMessage: StoredMessage = {
id: randomString(),
role: "user",
content: input.trim(),
createdAt: new Date().toISOString()
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput("");
setIsStreaming(true);
setStreamingContent("");
setStreamingThinking("");
let assistantContent = "";
let thinkingContent = "";
const citations: LlmCitation[] = [];
const toolCalls: ToolCall[] = [];
let usage: LlmUsage | undefined;
const apiMessages: LlmMessage[] = newMessages.map(m => ({
role: m.role,
content: m.content
}));
await streamChatCompletion(
apiMessages,
{ model: selectedModel || undefined, enableWebSearch, enableNoteTools },
{
onChunk: (text) => {
assistantContent += text;
setStreamingContent(assistantContent);
setToolActivity(null);
},
onThinking: (text) => {
thinkingContent += text;
setStreamingThinking(thinkingContent);
setToolActivity(t("llm_chat.thinking"));
},
onToolUse: (toolName, toolInput) => {
const toolLabel = toolName === "web_search"
? t("llm_chat.searching_web")
: `Using ${toolName}...`;
setToolActivity(toolLabel);
toolCalls.push({
id: randomString(),
toolName,
input: toolInput
});
},
onToolResult: (toolName, result) => {
const toolCall = [...toolCalls].reverse().find(tc => tc.toolName === toolName && !tc.result);
if (toolCall) {
toolCall.result = result;
}
},
onCitation: (citation) => {
citations.push(citation);
setPendingCitations([...citations]);
},
onUsage: (u) => {
usage = u;
},
onError: (errorMsg) => {
console.error("Chat error:", errorMsg);
const errorMessage: StoredMessage = {
id: randomString(),
role: "assistant",
content: errorMsg,
createdAt: new Date().toISOString(),
type: "error"
};
const finalMessages = [...newMessages, errorMessage];
setMessages(finalMessages);
saveChat(finalMessages);
setStreamingContent("");
setStreamingThinking("");
setIsStreaming(false);
setToolActivity(null);
},
onDone: () => {
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (assistantContent || toolCalls.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: assistantContent,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
const allMessages = [...newMessages, ...finalNewMessages];
setMessages(allMessages);
saveChat(allMessages);
}
setStreamingContent("");
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
setToolActivity(null);
}
}
);
}, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, saveChat, ensureChatNoteExists]);
// Delegate to shared handler
await chat.handleSubmit(e);
}, [chatNoteId, chat]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -321,17 +133,16 @@ export default function SidebarChat() {
}, [handleSubmit]);
const handleNewChat = useCallback(async () => {
// Create a fresh new chat
try {
const note = await dateNoteService.createLlmChat();
if (note) {
setChatNoteId(note.noteId);
setMessages([]);
chat.clearMessages();
}
} catch (err) {
console.error("Failed to create new chat:", err);
}
}, []);
}, [chat]);
const handleSaveChat = useCallback(async () => {
if (!chatNoteId) return;
@@ -341,12 +152,12 @@ export default function SidebarChat() {
const note = await dateNoteService.createLlmChat();
if (note) {
setChatNoteId(note.noteId);
setMessages([]);
chat.clearMessages();
}
} catch (err) {
console.error("Failed to save chat to permanent location:", err);
}
}, [chatNoteId]);
}, [chatNoteId, chat]);
return (
<RightPanelWidget
@@ -366,62 +177,62 @@ export default function SidebarChat() {
text=""
title={t("sidebar_chat.save_chat")}
onClick={handleSaveChat}
disabled={messages.length === 0}
disabled={chat.messages.length === 0}
/>
</>
}
>
<div className="sidebar-chat-container">
<div className="sidebar-chat-messages">
{messages.length === 0 && !isStreaming && (
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
text={t("sidebar_chat.empty_state")}
/>
)}
{messages.map(msg => (
{chat.messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{toolActivity && !streamingThinking && (
{chat.toolActivity && !chat.streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{toolActivity}
{chat.toolActivity}
</div>
)}
{isStreaming && streamingThinking && (
{chat.isStreaming && chat.streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: streamingThinking,
content: chat.streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{isStreaming && streamingContent && (
{chat.isStreaming && chat.streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: streamingContent,
content: chat.streamingContent,
createdAt: new Date().toISOString(),
citations: pendingCitations.length > 0 ? pendingCitations : undefined
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}
isStreaming
/>
)}
<div ref={messagesEndRef} />
<div ref={chat.messagesEndRef} />
</div>
<div className="sidebar-chat-input-area">
<textarea
ref={textareaRef}
ref={chat.textareaRef}
className="sidebar-chat-input"
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
value={chat.input}
onInput={(e) => chat.setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={isStreaming}
disabled={chat.isStreaming}
onKeyDown={handleKeyDown}
rows={2}
/>
@@ -430,18 +241,18 @@ export default function SidebarChat() {
<label className="sidebar-chat-toggle" title={t("llm_chat.web_search")}>
<input
type="checkbox"
checked={enableWebSearch}
onChange={() => setEnableWebSearch(v => !v)}
disabled={isStreaming}
checked={chat.enableWebSearch}
onChange={() => chat.setEnableWebSearch(!chat.enableWebSearch)}
disabled={chat.isStreaming}
/>
<span className="bx bx-globe" />
</label>
<label className="sidebar-chat-toggle" title={t("llm_chat.note_tools")}>
<input
type="checkbox"
checked={enableNoteTools}
onChange={() => setEnableNoteTools(v => !v)}
disabled={isStreaming}
checked={chat.enableNoteTools}
onChange={() => chat.setEnableNoteTools(!chat.enableNoteTools)}
disabled={chat.isStreaming}
/>
<span className="bx bx-note" />
</label>
@@ -449,7 +260,7 @@ export default function SidebarChat() {
<button
type="button"
className="sidebar-chat-send-btn"
disabled={isStreaming || !input.trim()}
disabled={chat.isStreaming || !chat.input.trim()}
onClick={handleSubmit}
>
<span className="bx bx-send" />

View File

@@ -1,166 +1,47 @@
import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { useCallback, useEffect, 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 { useEditorSpacedUpdate } from "../../react/hooks.js";
import FormDropdownList from "../../react/FormDropdownList.js";
import { TypeWidgetProps } from "../type_widget.js";
import ChatMessage from "./ChatMessage.js";
import type { LlmChatContent } from "./llm_chat_types.js";
import { useLlmChat } from "./useLlmChat.js";
import "./LlmChat.css";
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 LlmChatContent {
version: 1;
messages: StoredMessage[];
selectedModel?: string;
enableWebSearch?: boolean;
enableNoteTools?: boolean;
enableExtendedThinking?: boolean;
}
/** Extended model info with cost description for dropdown display */
interface ModelOption extends LlmModelInfo {
costDescription?: string;
}
/** Format token count with thousands separators */
function formatTokenCount(tokens: number): string {
return tokens.toLocaleString();
}
export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
const [messages, setMessages] = useState<StoredMessage[]>([]);
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<LlmCitation[]>([]);
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [enableWebSearch, setEnableWebSearch] = useState(true);
const [enableNoteTools, setEnableNoteTools] = useState(false);
const [enableExtendedThinking, setEnableExtendedThinking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [shouldSave, setShouldSave] = useState(false);
const [lastPromptTokens, setLastPromptTokens] = useState<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Fetch available models on mount
useEffect(() => {
getAvailableModels().then(models => {
// Add cost description for display
const modelsWithDescription = models.map(m => ({
...m,
costDescription: m.costMultiplier
? `${m.costMultiplier}x cost`
: undefined
}));
setAvailableModels(modelsWithDescription);
// Set default model if not already selected
if (!selectedModel) {
const defaultModel = models.find(m => m.isDefault) || models[0];
if (defaultModel) {
setSelectedModel(defaultModel.id);
}
}
}).catch(err => {
console.error("Failed to fetch available models:", err);
});
}, []);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]);
// Use a ref to store the latest messages for getData
const messagesRef = useRef(messages);
messagesRef.current = messages;
const enableWebSearchRef = useRef(enableWebSearch);
enableWebSearchRef.current = enableWebSearch;
const enableNoteToolsRef = useRef(enableNoteTools);
enableNoteToolsRef.current = enableNoteTools;
const enableExtendedThinkingRef = useRef(enableExtendedThinking);
enableExtendedThinkingRef.current = enableExtendedThinking;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const chat = useLlmChat(
// onMessagesChange - trigger save
() => setShouldSave(true),
{ defaultEnableNoteTools: false, supportsExtendedThinking: true }
);
const spacedUpdate = useEditorSpacedUpdate({
note,
noteType: "llmChat",
noteContext,
getData: () => {
// Use refs to get the latest values, avoiding stale closure issues
const content: LlmChatContent = {
version: 1,
messages: messagesRef.current,
selectedModel: selectedModelRef.current || undefined,
enableWebSearch: enableWebSearchRef.current,
enableNoteTools: enableNoteToolsRef.current,
enableExtendedThinking: enableExtendedThinkingRef.current
};
const content = chat.getContent();
return { content: JSON.stringify(content) };
},
onContentChange: (content) => {
if (!content) {
setMessages([]);
chat.clearMessages();
return;
}
try {
const parsed: LlmChatContent = JSON.parse(content);
setMessages(parsed.messages || []);
if (parsed.selectedModel) {
setSelectedModel(parsed.selectedModel);
}
if (typeof parsed.enableWebSearch === "boolean") {
setEnableWebSearch(parsed.enableWebSearch);
}
if (typeof parsed.enableNoteTools === "boolean") {
setEnableNoteTools(parsed.enableNoteTools);
}
if (typeof parsed.enableExtendedThinking === "boolean") {
setEnableExtendedThinking(parsed.enableExtendedThinking);
}
// Restore last prompt tokens from the most recent message with usage
const lastUsage = [...(parsed.messages || [])].reverse().find(m => m.usage)?.usage;
if (lastUsage) {
setLastPromptTokens(lastUsage.promptTokens);
}
chat.loadFromContent(parsed);
} catch (e) {
console.error("Failed to parse LLM chat content:", e);
setMessages([]);
chat.clearMessages();
}
}
});
@@ -173,254 +54,109 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
}
}, [shouldSave, spacedUpdate]);
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
setError(null);
setToolActivity(null);
setPendingCitations([]);
const userMessage: StoredMessage = {
id: randomString(),
role: "user",
content: input.trim(),
createdAt: new Date().toISOString()
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput("");
setIsStreaming(true);
setStreamingContent("");
setStreamingThinking("");
let assistantContent = "";
let thinkingContent = "";
const citations: LlmCitation[] = [];
const toolCalls: ToolCall[] = [];
let usage: LlmUsage | undefined;
const apiMessages: LlmMessage[] = newMessages.map(m => ({
role: m.role,
content: m.content
}));
await streamChatCompletion(
apiMessages,
{ model: selectedModel || undefined, enableWebSearch, enableNoteTools, enableExtendedThinking },
{
onChunk: (text) => {
assistantContent += text;
setStreamingContent(assistantContent);
setToolActivity(null); // Clear tool activity when text starts
},
onThinking: (text) => {
thinkingContent += text;
setStreamingThinking(thinkingContent);
setToolActivity(t("llm_chat.thinking"));
},
onToolUse: (toolName, toolInput) => {
const toolLabel = toolName === "web_search"
? t("llm_chat.searching_web")
: `Using ${toolName}...`;
setToolActivity(toolLabel);
// Track the tool call
toolCalls.push({
id: randomString(),
toolName,
input: toolInput
});
},
onToolResult: (toolName, result) => {
// Find the most recent tool call with this name and add the result
const toolCall = [...toolCalls].reverse().find(tc => tc.toolName === toolName && !tc.result);
if (toolCall) {
toolCall.result = result;
}
},
onCitation: (citation) => {
citations.push(citation);
setPendingCitations([...citations]);
},
onUsage: (u) => {
usage = u;
setLastPromptTokens(u.promptTokens);
},
onError: (errorMsg) => {
console.error("Chat error:", errorMsg);
// Persist error as an assistant message
const errorMessage: StoredMessage = {
id: randomString(),
role: "assistant",
content: errorMsg,
createdAt: new Date().toISOString(),
type: "error"
};
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: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (assistantContent || toolCalls.length > 0) {
newMessages.push({
id: randomString(),
role: "assistant",
content: assistantContent,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
usage
});
}
if (newMessages.length > 0) {
setMessages(prev => [...prev, ...newMessages]);
}
setStreamingContent("");
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
setToolActivity(null);
// Trigger save after state updates via useEffect
setShouldSave(true);
}
}
);
}, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [handleSubmit]);
const toggleWebSearch = useCallback(() => {
setEnableWebSearch(prev => !prev);
chat.setEnableWebSearch(!chat.enableWebSearch);
setShouldSave(true);
}, []);
}, [chat]);
const toggleNoteTools = useCallback(() => {
setEnableNoteTools(prev => !prev);
chat.setEnableNoteTools(!chat.enableNoteTools);
setShouldSave(true);
}, []);
}, [chat]);
const toggleExtendedThinking = useCallback(() => {
setEnableExtendedThinking(prev => !prev);
chat.setEnableExtendedThinking(!chat.enableExtendedThinking);
setShouldSave(true);
}, []);
}, [chat]);
const handleModelChange = useCallback((newModel: string) => {
setSelectedModel(newModel);
chat.setSelectedModel(newModel);
setShouldSave(true);
}, []);
}, [chat]);
return (
<div className="llm-chat-container">
<div className="llm-chat-messages">
{messages.length === 0 && !isStreaming && (
{chat.messages.length === 0 && !chat.isStreaming && (
<div className="llm-chat-empty">
{t("llm_chat.empty_state")}
</div>
)}
{messages.map(msg => (
{chat.messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{toolActivity && !streamingThinking && (
{chat.toolActivity && !chat.streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{toolActivity}
{chat.toolActivity}
</div>
)}
{isStreaming && streamingThinking && (
{chat.isStreaming && chat.streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: streamingThinking,
content: chat.streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{isStreaming && streamingContent && (
{chat.isStreaming && chat.streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: streamingContent,
content: chat.streamingContent,
createdAt: new Date().toISOString(),
citations: pendingCitations.length > 0 ? pendingCitations : undefined
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}
isStreaming
/>
)}
{error && (
<div className="llm-chat-error">
{error}
</div>
)}
<div ref={messagesEndRef} />
<div ref={chat.messagesEndRef} />
</div>
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
<form className="llm-chat-input-form" onSubmit={chat.handleSubmit}>
<div className="llm-chat-input-row">
<textarea
ref={textareaRef}
ref={chat.textareaRef}
className="llm-chat-input"
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
value={chat.input}
onInput={(e) => chat.setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={isStreaming}
onKeyDown={handleKeyDown}
disabled={chat.isStreaming}
onKeyDown={chat.handleKeyDown}
rows={3}
/>
<button
type="submit"
className="llm-chat-send-btn"
disabled={isStreaming || !input.trim()}
disabled={chat.isStreaming || !chat.input.trim()}
>
{isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
{chat.isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
</button>
</div>
<div className="llm-chat-options">
<div className="llm-chat-model-selector">
<span className="bx bx-chip" />
<FormDropdownList
values={availableModels}
values={chat.availableModels}
keyProperty="id"
titleProperty="name"
descriptionProperty="costDescription"
currentValue={selectedModel}
currentValue={chat.selectedModel}
onChange={handleModelChange}
disabled={isStreaming}
disabled={chat.isStreaming}
buttonClassName="llm-chat-model-select"
/>
</div>
<label className="llm-chat-toggle">
<input
type="checkbox"
checked={enableWebSearch}
checked={chat.enableWebSearch}
onChange={toggleWebSearch}
disabled={isStreaming}
disabled={chat.isStreaming}
/>
<span className="bx bx-globe" />
{t("llm_chat.web_search")}
@@ -428,9 +164,9 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
<label className="llm-chat-toggle">
<input
type="checkbox"
checked={enableNoteTools}
checked={chat.enableNoteTools}
onChange={toggleNoteTools}
disabled={isStreaming}
disabled={chat.isStreaming}
/>
<span className="bx bx-note" />
{t("llm_chat.note_tools")}
@@ -438,17 +174,17 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
<label className="llm-chat-toggle">
<input
type="checkbox"
checked={enableExtendedThinking}
checked={chat.enableExtendedThinking}
onChange={toggleExtendedThinking}
disabled={isStreaming}
disabled={chat.isStreaming}
/>
<span className="bx bx-brain" />
{t("llm_chat.extended_thinking")}
</label>
{lastPromptTokens > 0 && (() => {
const currentModel = availableModels.find(m => m.id === selectedModel);
{chat.lastPromptTokens > 0 && (() => {
const currentModel = chat.availableModels.find(m => m.id === chat.selectedModel);
const contextWindow = currentModel?.contextWindow || 200000;
const percentage = Math.min((lastPromptTokens / contextWindow) * 100, 100);
const percentage = Math.min((chat.lastPromptTokens / contextWindow) * 100, 100);
const isWarning = percentage > 75;
const isCritical = percentage > 90;
const color = isCritical ? "var(--danger-color, #d9534f)" : isWarning ? "var(--warning-color, #f0ad4e)" : "var(--main-selection-color, #007bff)";
@@ -456,7 +192,7 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
return (
<div
className="llm-chat-context-pie"
title={`${formatTokenCount(lastPromptTokens)} / ${formatTokenCount(contextWindow)} ${t("llm_chat.tokens")} (${percentage.toFixed(0)}%)`}
title={`${formatTokenCount(chat.lastPromptTokens)} / ${formatTokenCount(contextWindow)} ${t("llm_chat.tokens")} (${percentage.toFixed(0)}%)`}
style={{
background: `conic-gradient(${color} ${percentage}%, var(--accented-background-color) ${percentage}%)`
}}

View File

@@ -0,0 +1,33 @@
import type { LlmCitation, LlmUsage } from "@triliumnext/commons";
export type MessageType = "message" | "error" | "thinking";
export interface ToolCall {
id: string;
toolName: string;
input: Record<string, unknown>;
result?: string;
}
export 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;
}
export interface LlmChatContent {
version: 1;
messages: StoredMessage[];
selectedModel?: string;
enableWebSearch?: boolean;
enableNoteTools?: boolean;
enableExtendedThinking?: boolean;
}

View File

@@ -0,0 +1,339 @@
import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons";
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";
export interface ModelOption extends LlmModelInfo {
costDescription?: string;
}
export interface LlmChatOptions {
/** Default value for enableNoteTools */
defaultEnableNoteTools?: boolean;
/** Whether extended thinking is supported */
supportsExtendedThinking?: boolean;
}
export interface UseLlmChatReturn {
// State
messages: StoredMessage[];
input: string;
isStreaming: boolean;
streamingContent: string;
streamingThinking: string;
toolActivity: string | null;
pendingCitations: LlmCitation[];
availableModels: ModelOption[];
selectedModel: string;
enableWebSearch: boolean;
enableNoteTools: boolean;
enableExtendedThinking: boolean;
lastPromptTokens: number;
messagesEndRef: React.RefObject<HTMLDivElement>;
textareaRef: React.RefObject<HTMLTextAreaElement>;
// Setters
setInput: (value: string) => void;
setMessages: (messages: StoredMessage[]) => void;
setSelectedModel: (model: string) => void;
setEnableWebSearch: (value: boolean) => void;
setEnableNoteTools: (value: boolean) => void;
setEnableExtendedThinking: (value: boolean) => void;
// Actions
handleSubmit: (e: Event) => Promise<void>;
handleKeyDown: (e: KeyboardEvent) => void;
loadFromContent: (content: LlmChatContent) => void;
getContent: () => LlmChatContent;
clearMessages: () => void;
}
export function useLlmChat(
onMessagesChange?: (messages: StoredMessage[]) => void,
options: LlmChatOptions = {}
): UseLlmChatReturn {
const { defaultEnableNoteTools = false, supportsExtendedThinking = false } = options;
const [messages, setMessagesInternal] = useState<StoredMessage[]>([]);
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<LlmCitation[]>([]);
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [enableWebSearch, setEnableWebSearch] = useState(true);
const [enableNoteTools, setEnableNoteTools] = useState(defaultEnableNoteTools);
const [enableExtendedThinking, setEnableExtendedThinking] = useState(false);
const [lastPromptTokens, setLastPromptTokens] = useState<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Refs to get fresh values in getContent (avoids stale closures)
const messagesRef = useRef(messages);
messagesRef.current = messages;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const enableWebSearchRef = useRef(enableWebSearch);
enableWebSearchRef.current = enableWebSearch;
const enableNoteToolsRef = useRef(enableNoteTools);
enableNoteToolsRef.current = enableNoteTools;
const enableExtendedThinkingRef = useRef(enableExtendedThinking);
enableExtendedThinkingRef.current = enableExtendedThinking;
// Wrapper to call onMessagesChange when messages update
const setMessages = useCallback((newMessages: StoredMessage[]) => {
setMessagesInternal(newMessages);
onMessagesChange?.(newMessages);
}, [onMessagesChange]);
// Fetch available models on mount
useEffect(() => {
getAvailableModels().then(models => {
const modelsWithDescription = models.map(m => ({
...m,
costDescription: m.costMultiplier ? `${m.costMultiplier}x cost` : undefined
}));
setAvailableModels(modelsWithDescription);
if (!selectedModel) {
const defaultModel = models.find(m => m.isDefault) || models[0];
if (defaultModel) {
setSelectedModel(defaultModel.id);
}
}
}).catch(err => {
console.error("Failed to fetch available models:", err);
});
}, []);
// Scroll to bottom when content changes
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]);
// Load state from content object
const loadFromContent = useCallback((content: LlmChatContent) => {
setMessagesInternal(content.messages || []);
if (content.selectedModel) {
setSelectedModel(content.selectedModel);
}
if (typeof content.enableWebSearch === "boolean") {
setEnableWebSearch(content.enableWebSearch);
}
if (typeof content.enableNoteTools === "boolean") {
setEnableNoteTools(content.enableNoteTools);
}
if (supportsExtendedThinking && typeof content.enableExtendedThinking === "boolean") {
setEnableExtendedThinking(content.enableExtendedThinking);
}
// Restore last prompt tokens from the most recent message with usage
const lastUsage = [...(content.messages || [])].reverse().find(m => m.usage)?.usage;
if (lastUsage) {
setLastPromptTokens(lastUsage.promptTokens);
}
}, [supportsExtendedThinking]);
// Get current state as content object (uses refs to avoid stale closures)
const getContent = useCallback((): LlmChatContent => {
const content: LlmChatContent = {
version: 1,
messages: messagesRef.current,
selectedModel: selectedModelRef.current || undefined,
enableWebSearch: enableWebSearchRef.current,
enableNoteTools: enableNoteToolsRef.current
};
if (supportsExtendedThinking) {
content.enableExtendedThinking = enableExtendedThinkingRef.current;
}
return content;
}, [supportsExtendedThinking]);
const clearMessages = useCallback(() => {
setMessages([]);
}, [setMessages]);
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
setToolActivity(null);
setPendingCitations([]);
const userMessage: StoredMessage = {
id: randomString(),
role: "user",
content: input.trim(),
createdAt: new Date().toISOString()
};
const newMessages = [...messages, userMessage];
setMessagesInternal(newMessages);
setInput("");
setIsStreaming(true);
setStreamingContent("");
setStreamingThinking("");
let assistantContent = "";
let thinkingContent = "";
const citations: LlmCitation[] = [];
const toolCalls: ToolCall[] = [];
let usage: LlmUsage | undefined;
const apiMessages: LlmMessage[] = newMessages.map(m => ({
role: m.role,
content: m.content
}));
const streamOptions: Parameters<typeof streamChatCompletion>[1] = {
model: selectedModel || undefined,
enableWebSearch,
enableNoteTools
};
if (supportsExtendedThinking) {
streamOptions.enableExtendedThinking = enableExtendedThinking;
}
await streamChatCompletion(
apiMessages,
streamOptions,
{
onChunk: (text) => {
assistantContent += text;
setStreamingContent(assistantContent);
setToolActivity(null);
},
onThinking: (text) => {
thinkingContent += text;
setStreamingThinking(thinkingContent);
setToolActivity(t("llm_chat.thinking"));
},
onToolUse: (toolName, toolInput) => {
const toolLabel = toolName === "web_search"
? t("llm_chat.searching_web")
: `Using ${toolName}...`;
setToolActivity(toolLabel);
toolCalls.push({
id: randomString(),
toolName,
input: toolInput
});
},
onToolResult: (toolName, result) => {
const toolCall = [...toolCalls].reverse().find(tc => tc.toolName === toolName && !tc.result);
if (toolCall) {
toolCall.result = result;
}
},
onCitation: (citation) => {
citations.push(citation);
setPendingCitations([...citations]);
},
onUsage: (u) => {
usage = u;
setLastPromptTokens(u.promptTokens);
},
onError: (errorMsg) => {
console.error("Chat error:", errorMsg);
const errorMessage: StoredMessage = {
id: randomString(),
role: "assistant",
content: errorMsg,
createdAt: new Date().toISOString(),
type: "error"
};
const finalMessages = [...newMessages, errorMessage];
setMessages(finalMessages);
setStreamingContent("");
setStreamingThinking("");
setIsStreaming(false);
setToolActivity(null);
},
onDone: () => {
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (assistantContent || toolCalls.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: assistantContent,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
const allMessages = [...newMessages, ...finalNewMessages];
setMessages(allMessages);
}
setStreamingContent("");
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
setToolActivity(null);
}
}
);
}, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking, supportsExtendedThinking, setMessages]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [handleSubmit]);
return {
// State
messages,
input,
isStreaming,
streamingContent,
streamingThinking,
toolActivity,
pendingCitations,
availableModels,
selectedModel,
enableWebSearch,
enableNoteTools,
enableExtendedThinking,
lastPromptTokens,
messagesEndRef,
textareaRef,
// Setters
setInput,
setMessages,
setSelectedModel,
setEnableWebSearch,
setEnableNoteTools,
setEnableExtendedThinking,
// Actions
handleSubmit,
handleKeyDown,
loadFromContent,
getContent,
clearMessages
};
}