diff --git a/CLAUDE.md b/CLAUDE.md index 5e70898951..be265e5bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,15 @@ Trilium provides powerful user scripting capabilities: - OpenID and TOTP authentication support - Sanitization of user-generated content +### Client-Side API Restrictions +- **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS +- Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead + +### Shared Types Policy +- Types shared between client and server belong in `@triliumnext/commons` (`packages/commons/src/lib/`) +- Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules +- Keep app-specific types (e.g., `LlmProvider` for server, `StreamCallbacks` for client) in their respective apps + ## Common Development Tasks ### Adding New Note Types diff --git a/apps/client/package.json b/apps/client/package.json index 4ce57fd9f0..d6c6544a6a 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -50,6 +50,7 @@ "clsx": "2.1.1", "color": "5.0.3", "debounce": "3.0.0", + "dompurify": "3.3.3", "draggabilly": "3.0.0", "force-graph": "1.51.2", "globals": "17.4.0", diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 1c1389810a..eca547e89c 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -508,7 +508,7 @@ type EventMappings = { contentSafeMarginChanged: { top: number; noteContext: NoteContext; - } + }; }; export type EventListener = { diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 4082671b87..12fd311bec 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -18,7 +18,7 @@ const RELATION = "relation"; * end user. Those types should be used only for checking against, they are * not for direct use. */ -export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet"; +export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet" | "llmChat"; export interface NotePathRecord { isArchived: boolean; diff --git a/apps/client/src/services/date_notes.ts b/apps/client/src/services/date_notes.ts index 21709b1bb0..f452e55dec 100644 --- a/apps/client/src/services/date_notes.ts +++ b/apps/client/src/services/date_notes.ts @@ -84,6 +84,55 @@ async function createSearchNote(opts = {}) { return await froca.getNote(note.noteId); } +async function createLlmChat() { + const note = await server.post("special-notes/llm-chat"); + + await ws.waitForMaxKnownEntityChangeId(); + + return await froca.getNote(note.noteId); +} + +/** + * Gets the most recently modified LLM chat. + * Returns null if no chat exists. + */ +async function getMostRecentLlmChat() { + const note = await server.get("special-notes/most-recent-llm-chat"); + + if (!note) { + return null; + } + + await ws.waitForMaxKnownEntityChangeId(); + + return await froca.getNote(note.noteId); +} + +/** + * Gets the most recent LLM chat, or creates a new one if none exists. + * Used by sidebar chat for persistent conversations across page refreshes. + */ +async function getOrCreateLlmChat() { + const note = await server.get("special-notes/get-or-create-llm-chat"); + + await ws.waitForMaxKnownEntityChangeId(); + + return await froca.getNote(note.noteId); +} + +export interface RecentLlmChat { + noteId: string; + title: string; + dateModified: string; +} + +/** + * Gets a list of recent LLM chats for the history popup. + */ +async function getRecentLlmChats(limit: number = 10): Promise { + return await server.get(`special-notes/recent-llm-chats?limit=${limit}`); +} + export default { getInboxNote, getTodayNote, @@ -94,5 +143,9 @@ export default { getMonthNote, getYearNote, createSqlConsole, - createSearchNote + createSearchNote, + createLlmChat, + getMostRecentLlmChat, + getOrCreateLlmChat, + getRecentLlmChats }; diff --git a/apps/client/src/services/experimental_features.ts b/apps/client/src/services/experimental_features.ts index 8cfbe126e8..d56836ef6b 100644 --- a/apps/client/src/services/experimental_features.ts +++ b/apps/client/src/services/experimental_features.ts @@ -13,6 +13,11 @@ export const experimentalFeatures = [ id: "new-layout", name: t("experimental_features.new_layout_name"), description: t("experimental_features.new_layout_description"), + }, + { + id: "llm", + name: t("experimental_features.llm_name"), + description: t("experimental_features.llm_description"), } ] as const satisfies ExperimentalFeature[]; diff --git a/apps/client/src/services/in_app_help.ts b/apps/client/src/services/in_app_help.ts index ce4c0cdd15..4db04f3c4b 100644 --- a/apps/client/src/services/in_app_help.ts +++ b/apps/client/src/services/in_app_help.ts @@ -19,7 +19,8 @@ export const byNoteType: Record, string | null> = { search: null, text: null, webView: null, - spreadsheet: null + spreadsheet: null, + llmChat: null }; export const byBookType: Record = { diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts new file mode 100644 index 0000000000..13f282fe02 --- /dev/null +++ b/apps/client/src/services/llm_chat.ts @@ -0,0 +1,110 @@ +import type { LlmChatConfig, LlmCitation, LlmMessage, LlmModelInfo,LlmUsage } from "@triliumnext/commons"; + +import server from "./server.js"; + +/** + * Fetch available models for a provider. + */ +export async function getAvailableModels(provider: string = "anthropic"): Promise { + const response = await server.get<{ models?: LlmModelInfo[] }>(`llm-chat/models?provider=${encodeURIComponent(provider)}`); + return response.models ?? []; +} + +export interface StreamCallbacks { + onChunk: (text: string) => void; + onThinking?: (text: string) => void; + onToolUse?: (toolName: string, input: Record) => void; + onToolResult?: (toolName: string, result: string, isError?: boolean) => void; + onCitation?: (citation: LlmCitation) => void; + onUsage?: (usage: LlmUsage) => void; + onError: (error: string) => void; + onDone: () => void; +} + +/** + * Stream a chat completion from the LLM API using Server-Sent Events. + */ +export async function streamChatCompletion( + messages: LlmMessage[], + config: LlmChatConfig, + callbacks: StreamCallbacks +): Promise { + const headers = await server.getHeaders(); + + const response = await fetch(`${window.glob.baseApiUrl}llm-chat/stream`, { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json" + } as HeadersInit, + body: JSON.stringify({ messages, config }) + }); + + if (!response.ok) { + callbacks.onError(`HTTP ${response.status}: ${response.statusText}`); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + callbacks.onError("No response body"); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)); + + switch (data.type) { + case "text": + callbacks.onChunk(data.content); + break; + case "thinking": + callbacks.onThinking?.(data.content); + break; + case "tool_use": + callbacks.onToolUse?.(data.toolName, data.toolInput); + break; + case "tool_result": + callbacks.onToolResult?.(data.toolName, data.result, data.isError); + break; + case "citation": + if (data.citation) { + callbacks.onCitation?.(data.citation); + } + break; + case "usage": + if (data.usage) { + callbacks.onUsage?.(data.usage); + } + break; + case "error": + callbacks.onError(data.error); + break; + case "done": + callbacks.onDone(); + break; + } + } catch (e) { + console.error("Failed to parse SSE data line:", line, e); + } + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index 0047439c82..c99f04ae81 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -1,6 +1,7 @@ import type { NoteType } from "../entities/fnote.js"; import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js"; import type { TreeCommandNames } from "../menus/tree_context_menu.js"; +import { isExperimentalFeatureEnabled } from "./experimental_features.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; import server from "./server.js"; @@ -41,6 +42,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [ { type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" }, // Misc note types + { type: "llmChat", mime: "application/json", title: t("note_types.llm-chat"), icon: "bx-message-square-dots", isBeta: true }, { type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" }, { type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true }, { type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" }, @@ -92,6 +94,7 @@ async function getNoteTypeItems(command?: TreeCommandNames) { function getBlankNoteTypes(command?: TreeCommandNames): MenuItem[] { return NOTE_TYPES .filter((nt) => !nt.reserved && nt.type !== "book") + .filter((nt) => nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm")) .map((nt) => { const menuItem: MenuCommandItem = { title: nt.title, diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index bc35a0bd3f..30b5e4eaf7 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -922,6 +922,7 @@ export default { parseDate, formatDateISO, formatDateTime, + formatTime, formatTimeInterval, formatSize, localNowDateTime, diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 5a462b9804..8dde2d580c 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -1750,10 +1750,13 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu { justify-content: space-between; align-items: baseline; font-weight: bold; - text-transform: uppercase; color: var(--muted-text-color) !important; } +#right-pane .card-header-title { + text-transform: uppercase; +} + #right-pane .card-header-buttons { display: flex; transform: scale(0.9); diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 27891a02ab..8cbf27d4d9 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1157,7 +1157,9 @@ "title": "Experimental Options", "disclaimer": "These options are experimental and may cause instability. Use with caution.", "new_layout_name": "New Layout", - "new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases." + "new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases.", + "llm_name": "AI / LLM Chat", + "llm_description": "Enable the AI chat sidebar and LLM chat notes powered by large language models." }, "fonts": { "theme_defined": "Theme defined", @@ -1599,6 +1601,7 @@ "geo-map": "Geo Map", "beta-feature": "Beta", "ai-chat": "AI Chat", + "llm-chat": "AI Chat", "task-list": "Task List", "new-feature": "New", "collections": "Collections", @@ -1610,6 +1613,49 @@ "toggle-on-hint": "Note is not protected, click to make it protected", "toggle-off-hint": "Note is protected, click to make it unprotected" }, + "llm_chat": { + "placeholder": "Type a message...", + "send": "Send", + "sending": "Sending...", + "empty_state": "Start a conversation by typing a message below.", + "searching_web": "Searching the web...", + "web_search": "Web search", + "note_tools": "Note access", + "sources": "Sources", + "extended_thinking": "Extended thinking", + "legacy_models": "Legacy models", + "thinking": "Thinking...", + "thought_process": "Thought process", + "tool_calls": "{{count}} tool call(s)", + "input": "Input", + "result": "Result", + "error": "Error", + "tool_error": "failed", + "total_tokens": "{{total}} tokens", + "tokens_detail": "{{prompt}} prompt + {{completion}} completion", + "tokens_used": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens", + "tokens_used_with_cost": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})", + "tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens", + "tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})", + "tokens": "tokens", + "context_used": "{{percentage}}% used", + "note_context_enabled": "Click to disable note context: {{title}}", + "note_context_disabled": "Click to include current note in context", + "no_provider_message": "No AI provider configured. Add one to start chatting.", + "add_provider": "Add AI Provider", + "role_user": "You", + "role_assistant": "Assistant" + }, + "sidebar_chat": { + "title": "AI Chat", + "launcher_title": "Open AI Chat", + "new_chat": "Start new chat", + "save_chat": "Save chat to notes", + "empty_state": "Start a conversation", + "history": "Chat history", + "recent_chats": "Recent chats", + "no_chats": "No previous chats" + }, "shared_switch": { "shared": "Shared", "toggle-on-title": "Share the note", @@ -2230,5 +2276,21 @@ "sample_xy": "XY", "sample_venn": "Venn", "sample_ishikawa": "Ishikawa" + }, + "llm": { + "settings_title": "AI / LLM", + "settings_description": "Configure AI and Large Language Model integrations.", + "add_provider": "Add Provider", + "add_provider_title": "Add AI Provider", + "configured_providers": "Configured Providers", + "no_providers_configured": "No providers configured yet.", + "provider_name": "Name", + "provider_type": "Provider", + "actions": "Actions", + "delete_provider": "Delete", + "delete_provider_confirmation": "Are you sure you want to delete the provider \"{{name}}\"?", + "api_key": "API Key", + "api_key_placeholder": "Enter your API key", + "cancel": "Cancel" } } diff --git a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx index 79de559c98..d5d443084b 100644 --- a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx +++ b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx @@ -1,6 +1,7 @@ import { useCallback, useLayoutEffect, useState } from "preact/hooks"; import FNote from "../../entities/fnote"; +import { isExperimentalFeatureEnabled } from "../../services/experimental_features"; import froca from "../../services/froca"; import { isDesktop, isMobile } from "../../services/utils"; import TabSwitcher from "../mobile_widgets/TabSwitcher"; @@ -12,6 +13,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 +100,8 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) { return ; case "mobileTabSwitcher": return ; + case "sidebarChat": + return isExperimentalFeatureEnabled("llm") ? : undefined; 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..15bcfbb525 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/SidebarChatButton.tsx @@ -0,0 +1,24 @@ +import { useCallback } from "preact/hooks"; + +import appContext from "../../components/app_context"; +import { t } from "../../services/i18n"; +import { LaunchBarActionButton } from "./launch_bar_widgets"; + +/** + * Launcher button to open the sidebar (which contains the chat). + * The chat widget is always visible in the sidebar for non-chat notes. + */ +export default function SidebarChatButton() { + const handleClick = useCallback(() => { + // Open right pane if hidden, or toggle it if visible + appContext.triggerEvent("toggleRightPane", {}); + }, []); + + return ( + + ); +} diff --git a/apps/client/src/widgets/layout/NoteTypeSwitcher.tsx b/apps/client/src/widgets/layout/NoteTypeSwitcher.tsx index e345249add..cf685828aa 100644 --- a/apps/client/src/widgets/layout/NoteTypeSwitcher.tsx +++ b/apps/client/src/widgets/layout/NoteTypeSwitcher.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "preact/hooks"; import FNote from "../../entities/fnote"; import attributes from "../../services/attributes"; +import { isExperimentalFeatureEnabled } from "../../services/experimental_features"; import froca from "../../services/froca"; import { t } from "../../services/i18n"; import { NOTE_TYPES, NoteTypeMapping } from "../../services/note_types"; @@ -28,6 +29,7 @@ export default function NoteTypeSwitcher() { const restNoteTypes: NoteTypeMapping[] = []; for (const noteType of NOTE_TYPES) { if (noteType.reserved || noteType.static || noteType.type === "book") continue; + if (noteType.type === "llmChat" && !isExperimentalFeatureEnabled("llm")) continue; if (SWITCHER_PINNED_NOTE_TYPES.has(noteType.type)) { pinnedNoteTypes.push(noteType); } else { diff --git a/apps/client/src/widgets/note_types.tsx b/apps/client/src/widgets/note_types.tsx index b80d4d545e..15023fbcf0 100644 --- a/apps/client/src/widgets/note_types.tsx +++ b/apps/client/src/widgets/note_types.tsx @@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget"; * A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one, * for protected session or attachment information. */ -export type ExtendedNoteType = Exclude | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole"; +export type ExtendedNoteType = Exclude | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat"; export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined); type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget); @@ -147,5 +147,11 @@ export const TYPE_MAPPINGS: Record = { className: "note-detail-spreadsheet", printable: true, isFullHeight: true + }, + llmChat: { + view: () => import("./type_widgets/llm_chat/LlmChat"), + className: "note-detail-llm-chat", + printable: true, + isFullHeight: true } }; diff --git a/apps/client/src/widgets/react/FormDropdownList.tsx b/apps/client/src/widgets/react/FormDropdownList.tsx index 08d607a8c2..150fc4a724 100644 --- a/apps/client/src/widgets/react/FormDropdownList.tsx +++ b/apps/client/src/widgets/react/FormDropdownList.tsx @@ -5,16 +5,27 @@ interface FormDropdownList extends Omit { values: T[]; keyProperty: keyof T; titleProperty: keyof T; + /** Property to show as a small suffix next to the title */ + titleSuffixProperty?: keyof T; descriptionProperty?: keyof T; currentValue: string; onChange(newValue: string): void; } -export default function FormDropdownList({ values, keyProperty, titleProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList) { +export default function FormDropdownList({ values, keyProperty, titleProperty, titleSuffixProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList) { const currentValueData = values.find(value => value[keyProperty] === currentValue); + const renderTitle = (item: T) => { + const title = item[titleProperty] as string; + const suffix = titleSuffixProperty ? item[titleSuffixProperty] as string : null; + if (suffix) { + return <>{title} {suffix}; + } + return title; + }; + return ( - + {values.map(item => ( onChange(item[keyProperty] as string)} @@ -22,9 +33,9 @@ export default function FormDropdownList({ values, keyProperty, titleProperty description={descriptionProperty && item[descriptionProperty] as string} selected={currentValue === item[keyProperty]} > - {item[titleProperty] as string} + {renderTitle(item)} ))} ) -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/RawHtml.tsx b/apps/client/src/widgets/react/RawHtml.tsx index 4b93f783dd..502fc56f5d 100644 --- a/apps/client/src/widgets/react/RawHtml.tsx +++ b/apps/client/src/widgets/react/RawHtml.tsx @@ -1,3 +1,4 @@ +import DOMPurify from "dompurify"; import type { CSSProperties, HTMLProps, RefObject } from "preact/compat"; type HTMLElementLike = string | HTMLElement | JQuery; @@ -14,16 +15,16 @@ export default function RawHtml({containerRef, ...props}: RawHtmlProps & { conta } export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject}) { - return
+ return
; } function getProps({ className, html, style, onClick }: RawHtmlProps) { return { - className: className, + className, dangerouslySetInnerHTML: getHtml(html ?? ""), style, onClick - } + }; } export function getHtml(html: string | HTMLElement | JQuery) { @@ -39,3 +40,19 @@ export function getHtml(html: string | HTMLElement | JQuery) { __html: html as string }; } + +/** + * Renders HTML content sanitized via DOMPurify to prevent XSS. + * Use this instead of {@link RawHtml} when the HTML originates from + * untrusted sources (e.g. LLM responses, user-generated markdown). + */ +export function SanitizedHtml({ className, html, style }: { className?: string; html: string; style?: CSSProperties }) { + return ( +
+ ); +} diff --git a/apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx b/apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx index 5dbd0d29cb..4fb2358d30 100644 --- a/apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx @@ -7,6 +7,7 @@ import branches from "../../services/branches"; import dialog from "../../services/dialog"; import { getAvailableLocales, t } from "../../services/i18n"; import mime_types from "../../services/mime_types"; +import { isExperimentalFeatureEnabled } from "../../services/experimental_features"; import { NOTE_TYPES } from "../../services/note_types"; import protected_session from "../../services/protected_session"; import server from "../../services/server"; @@ -72,7 +73,7 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note noCodeNotes?: boolean; }) { const mimeTypes = useMimeTypes(); - const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static), []); + const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static && (nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))), []); const changeNoteType = useCallback(async (type: NoteType, mime?: string) => { if (!note || (type === currentNoteType && mime === currentNoteMime)) { return; diff --git a/apps/client/src/widgets/ribbon/NoteActions.tsx b/apps/client/src/widgets/ribbon/NoteActions.tsx index 46a0d9d6b7..df47348ae1 100644 --- a/apps/client/src/widgets/ribbon/NoteActions.tsx +++ b/apps/client/src/widgets/ribbon/NoteActions.tsx @@ -85,7 +85,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote ); const isElectron = getIsElectron(); const isMac = getIsMac(); - const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType); + const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet", "llmChat"].includes(noteType); const isSearchOrBook = ["search", "book"].includes(noteType); const isHelpPage = note.noteId.startsWith("_help"); const [syncServerHost] = useTriliumOption("syncServerHost"); diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 082b0a66f0..b758cea301 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import appContext from "../../components/app_context"; import { WidgetsByParent } from "../../services/bundle"; +import { isExperimentalFeatureEnabled } from "../../services/experimental_features"; import { t } from "../../services/i18n"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; @@ -19,6 +20,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 +93,11 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) { el: , enabled: noteType === "text" && highlightsList.length > 0, }, + { + el: , + enabled: noteType !== "llmChat" && isExperimentalFeatureEnabled("llm"), + position: 1000 + }, ...widgetsByParent.getLegacyWidgets("right-pane").map((widget) => ({ el: , enabled: true, diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index 099a370899..604b39e7c4 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -51,7 +51,7 @@ export default function RightPanelWidget({ id, title, buttons, children, contain >
{title}
-
+
e.stopPropagation()}> {buttons} {contextMenuItems && (