feat(llm): basic sidebar implementation

This commit is contained in:
Elian Doran
2026-03-29 19:35:33 +03:00
parent 59c007e801
commit 0ccf10bbbb
9 changed files with 695 additions and 2 deletions

View File

@@ -508,7 +508,9 @@ type EventMappings = {
contentSafeMarginChanged: {
top: number;
noteContext: NoteContext;
}
};
toggleSidebarChat: {};
openSidebarChat: {};
};
export type EventListener<T extends EventNames> = {

View File

@@ -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",

View File

@@ -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 <QuickSearchLauncherWidget />;
case "mobileTabSwitcher":
return <TabSwitcher />;
case "sidebarChat":
return <SidebarChatButton />;
default:
console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}

View File

@@ -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 (
<LaunchBarActionButton
icon="bx bx-message-square-dots"
text={t("sidebar_chat.launcher_title")}
onClick={handleClick}
/>
);
}

View File

@@ -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: <HighlightsList />,
enabled: noteType === "text" && highlightsList.length > 0,
},
{
el: <SidebarChat />,
enabled: true,
position: 1000
},
...widgetsByParent.getLegacyWidgets("right-pane").map((widget) => ({
el: <CustomLegacyWidget key={widget._noteId} originalWidget={widget as LegacyRightPanelWidget} />,
enabled: true,

View File

@@ -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;
}

View File

@@ -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<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.
*/
export default function SidebarChat() {
const [visible, setVisible] = useState(false);
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 saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// 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 (
<RightPanelWidget
id="sidebar-chat"
title={t("sidebar_chat.title")}
grow
buttons={
<>
<ActionButton
icon="bx bx-plus"
text=""
title={t("sidebar_chat.new_chat")}
onClick={handleNewChat}
/>
<ActionButton
icon="bx bx-save"
text=""
title={t("sidebar_chat.save_chat")}
onClick={handleSaveChat}
disabled={messages.length === 0}
/>
</>
}
>
<div className="sidebar-chat-container">
<div className="sidebar-chat-messages">
{messages.length === 0 && !isStreaming && (
<div className="sidebar-chat-empty">
{t("llm_chat.empty_state")}
</div>
)}
{messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{toolActivity && !streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{toolActivity}
</div>
)}
{isStreaming && streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{isStreaming && streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: streamingContent,
createdAt: new Date().toISOString(),
citations: pendingCitations.length > 0 ? pendingCitations : undefined
}}
isStreaming
/>
)}
<div ref={messagesEndRef} />
</div>
<div className="sidebar-chat-input-area">
<textarea
ref={textareaRef}
className="sidebar-chat-input"
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={isStreaming}
onKeyDown={handleKeyDown}
rows={2}
/>
<div className="sidebar-chat-actions">
<div className="sidebar-chat-options">
<label className="sidebar-chat-toggle" title={t("llm_chat.web_search")}>
<input
type="checkbox"
checked={enableWebSearch}
onChange={() => setEnableWebSearch(v => !v)}
disabled={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}
/>
<span className="bx bx-note" />
</label>
</div>
<button
type="button"
className="sidebar-chat-send-btn"
disabled={isStreaming || !input.trim()}
onClick={handleSubmit}
>
<span className="bx bx-send" />
</button>
</div>
</div>
</div>
</RightPanelWidget>
);
}

View File

@@ -357,7 +357,8 @@
"user-guide": "User Guide",
"localization": "Language & Region",
"inbox-title": "Inbox",
"tab-switcher-title": "Tab Switcher"
"tab-switcher-title": "Tab Switcher",
"sidebar-chat-title": "AI Chat"
},
"notes": {
"new-note": "New note",

View File

@@ -78,6 +78,13 @@ export default function buildLaunchBarConfig() {
type: "launcher",
command: "toggleZenMode",
icon: "bx bxs-yin-yang"
},
{
id: "_lbSidebarChat",
title: t("hidden-subtree.sidebar-chat-title"),
type: "launcher",
builtinWidget: "sidebarChat",
icon: "bx bx-message-square-dots"
}
];