mirror of
https://github.com/zadam/trilium.git
synced 2026-07-13 18:03:58 +02:00
refactor(llm): deduplicate LLM chat widgets
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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}%)`
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
339
apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
Normal file
339
apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user