diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 1c1389810a..296318dd41 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -508,7 +508,9 @@ type EventMappings = { contentSafeMarginChanged: { top: number; noteContext: NoteContext; - } + }; + toggleSidebarChat: {}; + openSidebarChat: {}; }; export type EventListener = { diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 23fcc7788b..9ad4c4185a 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1632,6 +1632,12 @@ "tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})", "tokens": "tokens" }, + "sidebar_chat": { + "title": "AI Chat", + "launcher_title": "Open AI Chat", + "new_chat": "Start new chat", + "save_chat": "Save chat to notes" + }, "shared_switch": { "shared": "Shared", "toggle-on-title": "Share the note", diff --git a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx index 79de559c98..d53ec6fb88 100644 --- a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx +++ b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx @@ -12,6 +12,7 @@ import HistoryNavigationButton from "./HistoryNavigation"; import { LaunchBarContext } from "./launch_bar_widgets"; import { CommandButton, CustomWidget, NoteLauncher, QuickSearchLauncherWidget, ScriptLauncher, TodayLauncher } from "./LauncherDefinitions"; import ProtectedSessionStatusWidget from "./ProtectedSessionStatusWidget"; +import SidebarChatButton from "./SidebarChatButton"; import SpacerWidget from "./SpacerWidget"; import SyncStatus from "./SyncStatus"; @@ -98,6 +99,8 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) { return ; case "mobileTabSwitcher": return ; + case "sidebarChat": + return ; default: console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`); } diff --git a/apps/client/src/widgets/launch_bar/SidebarChatButton.tsx b/apps/client/src/widgets/launch_bar/SidebarChatButton.tsx new file mode 100644 index 0000000000..2f8759a3ac --- /dev/null +++ b/apps/client/src/widgets/launch_bar/SidebarChatButton.tsx @@ -0,0 +1,29 @@ +import { useCallback } from "preact/hooks"; + +import appContext from "../../components/app_context"; +import { t } from "../../services/i18n"; +import options from "../../services/options"; +import { LaunchBarActionButton } from "./launch_bar_widgets"; + +/** + * Launcher button to open the sidebar chat. + * Opens the right pane if hidden, then activates the chat widget. + */ +export default function SidebarChatButton() { + const handleClick = useCallback(() => { + // Ensure right pane is visible + if (!options.is("rightPaneVisible")) { + appContext.triggerEvent("toggleRightPane", {}); + } + // Open the sidebar chat + appContext.triggerEvent("openSidebarChat", {}); + }, []); + + return ( + + ); +} diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 082b0a66f0..5adf7a495e 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -19,6 +19,7 @@ import PdfAttachments from "./pdf/PdfAttachments"; import PdfLayers from "./pdf/PdfLayers"; import PdfPages from "./pdf/PdfPages"; import RightPanelWidget from "./RightPanelWidget"; +import SidebarChat from "./SidebarChat"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; @@ -91,6 +92,11 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) { el: , enabled: noteType === "text" && highlightsList.length > 0, }, + { + el: , + enabled: true, + position: 1000 + }, ...widgetsByParent.getLegacyWidgets("right-pane").map((widget) => ({ el: , enabled: true, diff --git a/apps/client/src/widgets/sidebar/SidebarChat.css b/apps/client/src/widgets/sidebar/SidebarChat.css new file mode 100644 index 0000000000..42c6a94834 --- /dev/null +++ b/apps/client/src/widgets/sidebar/SidebarChat.css @@ -0,0 +1,192 @@ +/* Sidebar Chat Widget Styles */ + +.sidebar-chat-container { + display: flex; + flex-direction: column; + height: 100%; + min-height: 300px; +} + +.sidebar-chat-messages { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sidebar-chat-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--muted-text-color); + font-style: italic; + font-size: 0.9rem; + text-align: center; + padding: 1rem; +} + +/* Reuse llm-chat-message styles but make them more compact */ +.sidebar-chat-messages .llm-chat-message { + padding: 0.5rem 0.75rem; + margin-bottom: 0; + max-width: 100%; + font-size: 0.9rem; +} + +.sidebar-chat-messages .llm-chat-message-role { + font-size: 0.75rem; +} + +.sidebar-chat-messages .llm-chat-tool-activity { + font-size: 0.85rem; + padding: 0.375rem 0.75rem; + margin-bottom: 0; + max-width: 100%; +} + +/* Input area */ +.sidebar-chat-input-area { + padding: 0.5rem; + border-top: 1px solid var(--main-border-color); + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.sidebar-chat-input { + width: 100%; + min-height: 50px; + max-height: 120px; + resize: vertical; + padding: 0.5rem; + border: 1px solid var(--main-border-color); + border-radius: 6px; + font-family: inherit; + font-size: 0.9rem; + background: var(--main-background-color); + color: var(--main-text-color); +} + +.sidebar-chat-input:focus { + outline: none; + border-color: var(--main-selection-color); + box-shadow: 0 0 0 2px var(--main-selection-color-soft, rgba(0, 123, 255, 0.25)); +} + +.sidebar-chat-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.sidebar-chat-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.sidebar-chat-options { + display: flex; + gap: 0.5rem; +} + +.sidebar-chat-toggle { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--muted-text-color); + padding: 0.25rem; +} + +.sidebar-chat-toggle input[type="checkbox"] { + display: none; +} + +.sidebar-chat-toggle .bx { + font-size: 1.1rem; +} + +.sidebar-chat-toggle:has(input:checked) { + color: var(--main-text-color); +} + +.sidebar-chat-toggle:has(input:checked) .bx { + color: var(--main-selection-color); +} + +.sidebar-chat-toggle:has(input:disabled) { + opacity: 0.5; + cursor: not-allowed; +} + +.sidebar-chat-send-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem 0.75rem; + background: var(--button-background-color); + border: 1px solid var(--button-border-color); + border-radius: 6px; + cursor: pointer; + color: var(--button-text-color); + transition: background-color 0.15s ease; +} + +.sidebar-chat-send-btn .bx { + font-size: 1rem; +} + +.sidebar-chat-send-btn:hover:not(:disabled) { + background: var(--button-hover-background-color, var(--button-background-color)); +} + +.sidebar-chat-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Make the widget grow to fill available space */ +.widget.grow { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.widget.grow .body-wrapper { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.widget.grow .card-body { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Compact markdown in sidebar */ +.sidebar-chat-messages .llm-chat-markdown { + font-size: 0.9rem; + line-height: 1.5; +} + +.sidebar-chat-messages .llm-chat-markdown p { + margin: 0 0 0.5em 0; +} + +.sidebar-chat-messages .llm-chat-markdown pre { + padding: 0.5rem; + font-size: 0.8rem; +} + +.sidebar-chat-messages .llm-chat-markdown code { + font-size: 0.85em; +} diff --git a/apps/client/src/widgets/sidebar/SidebarChat.tsx b/apps/client/src/widgets/sidebar/SidebarChat.tsx new file mode 100644 index 0000000000..5a78d2f5a8 --- /dev/null +++ b/apps/client/src/widgets/sidebar/SidebarChat.tsx @@ -0,0 +1,447 @@ +import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; + +import appContext from "../../components/app_context.js"; +import dateNoteService from "../../services/date_notes.js"; +import { t } from "../../services/i18n.js"; +import { getAvailableModels, streamChatCompletion } from "../../services/llm_chat.js"; +import options from "../../services/options.js"; +import server from "../../services/server.js"; +import { randomString } from "../../services/utils.js"; +import ActionButton from "../react/ActionButton.js"; +import { useTriliumEvent } from "../react/hooks.js"; +import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js"; +import RightPanelWidget from "./RightPanelWidget.js"; +import "./SidebarChat.css"; + +type MessageType = "message" | "error" | "thinking"; + +interface ToolCall { + id: string; + toolName: string; + input: Record; + 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. + */ +export default function SidebarChat() { + const [visible, setVisible] = useState(false); + const [chatNoteId, setChatNoteId] = useState(null); + const [messages, setMessages] = useState([]); + 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 [availableModels, setAvailableModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(""); + const [enableWebSearch, setEnableWebSearch] = useState(true); + const [enableNoteTools, setEnableNoteTools] = useState(true); // Default true for sidebar + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + const saveTimeoutRef = useRef>(); + + // Listen for toggle event + useTriliumEvent("toggleSidebarChat", useCallback(() => { + setVisible(v => { + const newValue = !v; + if (newValue && !chatNoteId) { + // Create a new chat when first opened + initializeChat(); + } + return newValue; + }); + }, [chatNoteId])); + + // Listen for open event (always opens, creates chat if needed) + useTriliumEvent("openSidebarChat", useCallback(() => { + setVisible(true); + if (!chatNoteId) { + initializeChat(); + } + // Ensure right pane is visible + if (!options.is("rightPaneVisible")) { + appContext.triggerEvent("toggleRightPane", {}); + } + }, [chatNoteId])); + + // 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 initializeChat = async () => { + try { + const note = await dateNoteService.createLlmChat(); + if (note) { + setChatNoteId(note.noteId); + setMessages([]); + } + } catch (err) { + console.error("Failed to create sidebar chat:", err); + } + }; + + 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); + } + }; + + const saveChat = useCallback(async (updatedMessages: StoredMessage[]) => { + if (!chatNoteId) return; + + // 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 + }; + + try { + await server.put(`notes/${chatNoteId}/data`, { + content: JSON.stringify(content) + }); + } catch (err) { + console.error("Failed to save chat:", err); + } + }, 500); + }, [chatNoteId, selectedModel, enableWebSearch, enableNoteTools]); + + 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]; + 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]); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }, [handleSubmit]); + + const handleNewChat = useCallback(async () => { + // Save current chat first if it has messages + if (chatNoteId && messages.length > 0) { + await saveChat(messages); + } + // Create a new chat + await initializeChat(); + }, [chatNoteId, messages, saveChat]); + + const handleSaveChat = useCallback(async () => { + if (!chatNoteId) return; + try { + await server.post("special-notes/save-llm-chat", { llmChatNoteId: chatNoteId }); + // After saving, create a new chat for continued use + await initializeChat(); + } catch (err) { + console.error("Failed to save chat to permanent location:", err); + } + }, [chatNoteId]); + + if (!visible) { + return null; + } + + return ( + + + + + } + > +
+
+ {messages.length === 0 && !isStreaming && ( +
+ {t("llm_chat.empty_state")} +
+ )} + {messages.map(msg => ( + + ))} + {toolActivity && !streamingThinking && ( +
+ + {toolActivity} +
+ )} + {isStreaming && streamingThinking && ( + + )} + {isStreaming && streamingContent && ( + 0 ? pendingCitations : undefined + }} + isStreaming + /> + )} +
+
+
+