diff --git a/CLAUDE.md b/CLAUDE.md index 9778f0de7b..5eb93b3a5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,14 +152,28 @@ SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/servi - Schema: `apps/server/src/assets/db/schema.sql` - Migrations: `apps/server/src/migrations/YYMMDD_HHMM__description.sql` -### Attribute Inheritance +### Internationalization +- Translation files in `apps/client/src/translations/` +- Supported languages: English, German, Spanish, French, Romanian, Chinese +- **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community +- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`) Three inheritance mechanisms: 1. **Standard**: `note.getInheritableAttributes()` walks parent tree 2. **Child prefix**: `child:label` on parent copies to children 3. **Template relation**: `#template=noteNoteId` includes template's inheritable attributes +### Attribute Inheritance + Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited. +### 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 ## Important Patterns @@ -198,3 +212,16 @@ Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited. - `apps/client/src/services/froca.ts` — Frontend cache - `apps/server/src/routes/routes.ts` — API route registration - `packages/trilium-core/src/services/sql/sql.ts` — Database abstraction + +### Server-Side Static Assets +- Static assets (templates, SQL, translations, etc.) go in `apps/server/src/assets/` +- Access them at runtime via `RESOURCE_DIR` from `apps/server/src/services/resource_dir.ts` (e.g. `path.join(RESOURCE_DIR, "llm", "skills", "file.md")`) +- **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory +- **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree + +## Build System Notes +- Uses pnpm for monorepo management +- Vite for fast development builds +- ESBuild for production optimization +- pnpm workspaces for dependency management +- Docker support with multi-stage builds diff --git a/apps/build-docs/package.json b/apps/build-docs/package.json index cb1569b23f..8b0996e20d 100644 --- a/apps/build-docs/package.json +++ b/apps/build-docs/package.json @@ -20,7 +20,7 @@ "@triliumnext/server": "workspace:*" }, "devDependencies": { - "@redocly/cli": "2.25.2", + "@redocly/cli": "2.25.3", "archiver": "7.0.1", "fs-extra": "11.3.4", "js-yaml": "4.1.1", diff --git a/apps/client/package.json b/apps/client/package.json index ca6e3b277d..587faf4e7a 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -35,21 +35,22 @@ "@triliumnext/highlightjs": "workspace:*", "@triliumnext/share-theme": "workspace:*", "@triliumnext/split.js": "workspace:*", - "@univerjs/preset-sheets-conditional-formatting": "0.18.0", - "@univerjs/preset-sheets-core": "0.18.0", - "@univerjs/preset-sheets-data-validation": "0.18.0", - "@univerjs/preset-sheets-filter": "0.18.0", - "@univerjs/preset-sheets-find-replace": "0.18.0", - "@univerjs/preset-sheets-note": "0.18.0", - "@univerjs/preset-sheets-sort": "0.18.0", - "@univerjs/presets": "0.18.0", - "@zumer/snapdom": "2.6.0", + "@univerjs/preset-sheets-conditional-formatting": "0.19.0", + "@univerjs/preset-sheets-core": "0.19.0", + "@univerjs/preset-sheets-data-validation": "0.19.0", + "@univerjs/preset-sheets-filter": "0.19.0", + "@univerjs/preset-sheets-find-replace": "0.19.0", + "@univerjs/preset-sheets-note": "0.19.0", + "@univerjs/preset-sheets-sort": "0.19.0", + "@univerjs/presets": "0.19.0", + "@zumer/snapdom": "2.7.0", "autocomplete.js": "0.38.1", "bootstrap": "5.3.8", "boxicons": "2.1.4", "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", @@ -64,11 +65,11 @@ "mark.js": "8.11.1", "marked": "17.0.5", "mermaid": "11.13.0", - "mind-elixir": "5.9.3", + "mind-elixir": "5.10.0", "normalize.css": "8.0.1", "panzoom": "9.4.4", "preact": "10.29.0", - "react-i18next": "17.0.0", + "react-i18next": "17.0.1", "react-window": "2.2.7", "reveal.js": "6.0.0", "rrule": "2.8.1", 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..e4263aa896 --- /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 from all configured providers. + */ +export async function getAvailableModels(): Promise { + const response = await server.get<{ models?: LlmModelInfo[] }>("llm-chat/models"); + 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 2afb326f32..6ff43f545c 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -928,6 +928,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 6afd867eae..047f022a26 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", @@ -2281,5 +2327,35 @@ "language": "Language", "continue": "Continue", "your-ip-addresses": "Addresses for this device" + }, + "mind-map": { + "addChild": "Add child", + "addParent": "Add parent", + "addSibling": "Add sibling", + "removeNode": "Remove node", + "focus": "Focus Mode", + "cancelFocus": "Cancel Focus Mode", + "moveUp": "Move up", + "moveDown": "Move down", + "link": "Link", + "linkBidirectional": "Bidirectional Link", + "clickTips": "Please click the target node", + "summary": "Summary" + }, + "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/translations/fr/translation.json b/apps/client/src/translations/fr/translation.json index be019eaa16..8c549b612f 100644 --- a/apps/client/src/translations/fr/translation.json +++ b/apps/client/src/translations/fr/translation.json @@ -28,7 +28,10 @@ }, "widget-render-error": { "title": "Rendu impossible d'un widget React custom" - } + }, + "widget-missing-parent": "Le widget personnalisé ne possède pas la propriété obligatoire '{{property}}'.\n\nSi ce script est destiné à être exécuté sans élément d’interface utilisateur, utilisez plutôt '#run=frontendStartup'.", + "open-script-note": "Ouvrir la note du script", + "scripting-error": "Erreur de script personnalisée: {{title}}" }, "add_link": { "add_link": "Ajouter un lien", @@ -443,7 +446,8 @@ "and_more": "... et {{count}} plus.", "print_landscape": "Lors de l'exportation en PDF, change l'orientation de la page en paysage au lieu de portrait.", "print_page_size": "Lors de l'exportation en PDF, change la taille de la page. Valeurs supportées : A0, A1, A2, A3, A4, A5, A6, Legal, Letter, Tabloid, Ledger.", - "color_type": "Couleur" + "color_type": "Couleur", + "textarea": "Texte multiligne" }, "attribute_editor": { "help_text_body1": "Pour ajouter un label, tapez simplement par ex. #rock, ou si vous souhaitez également ajouter une valeur, tapez par ex. #année = 2020", @@ -659,7 +663,8 @@ "show-cheatsheet": "Afficher l'aide rapide", "toggle-zen-mode": "Zen Mode", "new-version-available": "Nouvelle mise à jour disponible", - "download-update": "Obtenir la version {{latestVersion}}" + "download-update": "Obtenir la version {{latestVersion}}", + "search_notes": "Rechercher notes" }, "zen_mode": { "button_exit": "Sortir du Zen mode" @@ -703,7 +708,8 @@ "advanced": "Avancé", "export_as_image": "Exporter en tant qu'image", "export_as_image_png": "PNG", - "export_as_image_svg": "SVG (vectoriel)" + "export_as_image_svg": "SVG (vectoriel)", + "note_map": "Note Carte" }, "onclick_button": { "no_click_handler": "Le widget bouton '{{componentId}}' n'a pas de gestionnaire de clic défini" @@ -741,7 +747,7 @@ "button_title": "Exporter le diagramme au format SVG" }, "relation_map_buttons": { - "create_child_note_title": "Créer une nouvelle note enfant et l'ajouter à cette carte de relation", + "create_child_note_title": "Créer une note enfant et l'ajouter à la carte", "reset_pan_zoom_title": "Réinitialiser le panoramique et le zoom aux coordonnées et à la position initiales", "zoom_in_title": "Zoomer", "zoom_out_title": "Zoom arrière" @@ -757,7 +763,9 @@ "delete_this_note": "Supprimer cette note", "error_cannot_get_branch_id": "Impossible d'obtenir branchId pour notePath '{{notePath}}'", "error_unrecognized_command": "Commande non reconnue {{command}}", - "note_revisions": "Révision de la note" + "note_revisions": "Révision de la note", + "backlinks": "Rétro-liens", + "content_language_switcher": "Langue du contenu: {{language}}" }, "note_icon": { "change_note_icon": "Changer l'icône de note", @@ -766,7 +774,12 @@ "filter": "Filtre", "filter-none": "Toutes les icônes", "filter-default": "Icônes par défaut", - "icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}" + "icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}", + "no_results": "Aucune icône trouvée.", + "search_placeholder_one": "Rechercher {{number}} icônes dans {{count}} packs", + "search_placeholder_many": "Rechercher {{number}} icônes dans {{count}} packs", + "search_placeholder_other": "Rechercher les icônes {{number}} dans les paquets {{count}}", + "search_placeholder_filtered": "Rechercher {{number}} icônes dans {{name}}" }, "basic_properties": { "note_type": "Type de note", @@ -793,7 +806,8 @@ "expand_tooltip": "Développe les éléments enfants directs de cette collection (à un niveau). Pour plus d'options, appuyez sur la flèche à droite.", "expand_first_level": "Développer les enfants directs", "expand_nth_level": "Développer sur {{depth}} niveaux", - "expand_all_levels": "Développer tous les niveaux" + "expand_all_levels": "Développer tous les niveaux", + "hide_child_notes": "Masquer les notes enfants dans l’arborescence" }, "edited_notes": { "no_edited_notes_found": "Aucune note modifiée ce jour-là...", @@ -806,7 +820,7 @@ "file_type": "Type de fichier", "file_size": "Taille du fichier", "download": "Télécharger", - "open": "Ouvrir", + "open": "Ouvrir dans une nouvelle fenêtre", "upload_new_revision": "Téléverser une nouvelle version", "upload_success": "Une nouvelle version de fichier a été téléversée.", "upload_failed": "Le téléversement d'une nouvelle version de fichier a échoué.", @@ -826,7 +840,8 @@ }, "inherited_attribute_list": { "title": "Attributs hérités", - "no_inherited_attributes": "Aucun attribut hérité." + "no_inherited_attributes": "Aucun attribut hérité.", + "none": "aucun" }, "note_info_widget": { "note_id": "Identifiant de la note", @@ -903,7 +918,8 @@ "unknown_search_option": "Option de recherche inconnue {{searchOptionName}}", "search_note_saved": "La note de recherche a été enregistrée dans {{- notePathTitle}}", "actions_executed": "Les actions ont été exécutées.", - "view_options": "Afficher les options:" + "view_options": "Afficher les options:", + "option": "option" }, "similar_notes": { "title": "Notes similaires", @@ -997,7 +1013,7 @@ "no_attachments": "Cette note ne contient aucune pièce jointe." }, "book": { - "no_children_help": "Cette note de type Livre n'a aucune note enfant, donc il n'y a rien à afficher. Consultez le wiki pour plus de détails.", + "no_children_help": "Cette collection ne contient pas de notes enfants, il n'y a donc rien à afficher.", "drag_locked_title": "Edition verrouillée", "drag_locked_message": "Le glisser-déposer n'est pas autorisé car l'édition de cette collection est verrouillé." }, @@ -1367,7 +1383,8 @@ "description": "Description", "reload_app": "Recharger l'application pour appliquer les modifications", "set_all_to_default": "Réinitialiser aux valeurs par défaut", - "confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?" + "confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?", + "no_results": "Aucun raccourci correspondant à '{{filter}}'" }, "spellcheck": { "title": "Vérification orthographique", @@ -1402,7 +1419,7 @@ "will_be_deleted_in": "Cette pièce jointe sera automatiquement supprimée dans {{time}}", "will_be_deleted_soon": "Cette pièce jointe sera bientôt supprimée automatiquement", "deletion_reason": ", car la pièce jointe n'est pas liée dans le contenu de la note. Pour empêcher la suppression, ajoutez à nouveau le lien de la pièce jointe dans le contenu d'une note ou convertissez la pièce jointe en note.", - "role_and_size": "Rôle : {{role}}, Taille : {{size}}", + "role_and_size": "Rôle : {{role}}, Taille : {{size}}, MIME: {{- mimeType}}", "link_copied": "Lien de pièce jointe copié dans le presse-papiers.", "unrecognized_role": "Rôle de pièce jointe « {{role}} » non reconnu." }, @@ -1453,10 +1470,13 @@ "import-into-note": "Importer dans la note", "apply-bulk-actions": "Appliquer des Actions groupées", "converted-to-attachments": "Les notes {{count}} ont été converties en pièces jointes.", - "convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentes ?", + "convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentales ? Cette opération s'applique uniquement aux notes d'image, les autres notes seront ignorées.", "archive": "Archive", "unarchive": "Désarchiver", - "open-in-popup": "Modification rapide" + "open-in-popup": "Modification rapide", + "open-in-a-new-window": "Ouvrir dans une nouvelle fenêtre", + "hide-subtree": "Masquer le sous-arbre", + "show-subtree": "Afficher le sous-arbre" }, "shared_info": { "shared_publicly": "Cette note est partagée publiquement sur {{- link}}.", @@ -1485,7 +1505,10 @@ "task-list": "Liste de tâches", "book": "Collection", "new-feature": "Nouveau", - "collections": "Collections" + "collections": "Collections", + "ai-chat": "Chat IA", + "llm-chat": "Chat AI", + "spreadsheet": "Feuille de calcul" }, "protect_note": { "toggle-on": "Protéger la note", @@ -1834,7 +1857,7 @@ "book_properties_config": { "hide-weekends": "Masquer les week-ends", "display-week-numbers": "Afficher les numéros de semaine", - "map-style": "Style de carte :", + "map-style": "Style de carte", "max-nesting-depth": "Profondeur d'imbrication maximale :", "raster": "Trame", "vector_light": "Vecteur (clair)", @@ -1973,7 +1996,9 @@ "title": "Options expérimentales", "disclaimer": "Ces options sont expérimentales et peuvent provoquer une instabilité. Utilisez avec prudence.", "new_layout_name": "Nouvelle mise en page", - "new_layout_description": "Essayez la nouvelle mise en page pour un look plus moderne et un usage améliorée. Sous réserve de changements importants dans les prochaines versions." + "new_layout_description": "Essayez la nouvelle mise en page pour un look plus moderne et un usage améliorée. Sous réserve de changements importants dans les prochaines versions.", + "llm_name": "AI / LLM Chat", + "llm_description": "Activer la barre de chat AI et les notes de chat LLM alimentées par de grands modèles de langage." }, "read-only-info": { "read-only-note": "Vous consultez actuellement une note en lecture seule.", @@ -1982,5 +2007,57 @@ }, "calendar_view": { "delete_note": "Effacer la note..." + }, + "media": { + "play": "Lire (Espace)", + "pause": "Pause (Espace)", + "back-10s": "Retour arrière 10s (flèche gauche)", + "forward-30s": "Avance 30s", + "mute": "Silence (M)", + "unmute": "Réactiver le son (M)", + "playback-speed": "Vitesse de lecture", + "loop": "Boucle", + "disable-loop": "Désactiver la boucle", + "rotate": "Rotation", + "picture-in-picture": "Image dans l'image", + "exit-picture-in-picture": "Sortir de Image dans l'image", + "fullscreen": "Plein-écran (F)", + "exit-fullscreen": "Sortir du mode plein-écran", + "unsupported-format": "L'aperçu multimédia n'est pas disponible pour ce format de fichier:\n{{mime}}", + "zoom-to-fit": "Zoom pour remplir", + "zoom-reset": "Annuler zoom pour remplir" + }, + "render": { + "setup_title": "Afficher du HTML personnalisé ou Preact JSX dans cette note", + "setup_create_sample_preact": "Créer un exemple de note avec Preact", + "setup_create_sample_html": "Créer un exemple de note avec HTML", + "setup_sample_created": "Un exemple de note a été créé en tant que note enfant.", + "disabled_description": "Ces notes de rendu proviennent d'une source externe. Pour vous protéger de contenu malveillant, elle n'est pas activée par défaut. Assurez-vous de faire confiance à la source avant de l’activer.", + "disabled_button_enable": "Activer la note de rendu" + }, + "web_view_setup": { + "title": "Créez la vue de la page Web directement dans Trilium", + "url_placeholder": "Entrez ou collez l'adresse du site Web, par exemple https://triliumnotes.org", + "create_button": "Créer une vue Web", + "invalid_url_title": "Adresse invalide", + "invalid_url_message": "Insérer une adresse Web valide, par exemple https://triliumnotes.org.", + "disabled_description": "Cette vue Web a été importée à partir d'une source externe. Pour vous protéger du phishing ou du contenu malveillant, elle ne se charge pas automatiquement. Vous pouvez l'activer si vous faites confiance à la source.", + "disabled_button_enable": "Activer la vue Web" + }, + "llm_chat": { + "placeholder": "Tapez un message...", + "send": "Envoyer", + "sending": "Envoi...", + "empty_state": "Démarrez une conversation en tapant un message ci-dessous.", + "searching_web": "Recherche sur le Web...", + "web_search": "Recherche sur le Web", + "note_tools": "Accès aux notes", + "sources": "Sources", + "extended_thinking": "Réflexion étendue", + "legacy_models": "Modèles hérités", + "thinking": "Réflexion...", + "thought_process": "Processus de réflexion", + "tool_calls": "{{count}} appel(s) d'outil", + "input": "Entrée" } } diff --git a/apps/client/src/translations/it/translation.json b/apps/client/src/translations/it/translation.json index ef4e0295a2..dec6c85169 100644 --- a/apps/client/src/translations/it/translation.json +++ b/apps/client/src/translations/it/translation.json @@ -1718,7 +1718,8 @@ "new-feature": "Nuovo", "collections": "Collezioni", "ai-chat": "Chat con IA", - "spreadsheet": "Foglio di calcolo" + "spreadsheet": "Foglio di calcolo", + "llm-chat": "Chat con IA" }, "protect_note": { "toggle-on": "Proteggi la nota", @@ -2051,7 +2052,9 @@ "title": "Opzioni sperimentali", "disclaimer": "Queste opzioni sono sperimentali e potrebbero causare instabilità. Usare con cautela.", "new_layout_name": "Nuovo layout", - "new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni." + "new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni.", + "llm_name": "Chat con IA / LLM", + "llm_description": "Attiva la barra laterale della chat con IA e le note della chat LLM basate su modelli linguistici di grandi dimensioni." }, "server": { "unknown_http_error_title": "Errore di comunicazione con il server", @@ -2245,5 +2248,64 @@ "sample_xy": "XY", "sample_venn": "Venn", "sample_ishikawa": "Ishikawa" + }, + "llm_chat": { + "placeholder": "Scrivi un messaggio...", + "send": "Invia", + "sending": "Invio in corso...", + "empty_state": "Inizia una conversazione scrivendo un messaggio qui sotto.", + "searching_web": "Ricerca sul web...", + "web_search": "Ricerca sul web", + "note_tools": "Nota di accesso", + "sources": "Fonti", + "extended_thinking": "Riflessioni approfondite", + "legacy_models": "Modelli precedenti", + "thinking": "Sto riflettendo...", + "thought_process": "Processo mentale", + "tool_calls": "{{count}} chiamata/e di funzione", + "input": "Dati in ingresso", + "result": "Risultato", + "error": "Errore", + "tool_error": "fallito", + "total_tokens": "{{total}} gettoni", + "tokens_detail": "{{prompt}} prompt + {{completion}} completamento", + "tokens_used": "{{prompt}} prompt + {{completion}} completamento = {{total}} token", + "tokens_used_with_cost": "{{prompt}} prompt + {{completion}} completamento = {{total}} token (~${{cost}})", + "tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} completamento = {{total}} token", + "tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} completamento = {{total}} token (~${{cost}})", + "tokens": "tokens", + "context_used": "{{percentage}}% utilizzato", + "note_context_enabled": "Clicca qui per disattivare il contesto della nota: {{title}}", + "note_context_disabled": "Clicca per includere la nota corrente nel contesto", + "no_provider_message": "Non è stato configurato alcun fornitore di IA. Aggiungine uno per iniziare a chattare.", + "add_provider": "Aggiungi un fornitore di IA", + "role_user": "Tu", + "role_assistant": "Assistente" + }, + "sidebar_chat": { + "title": "Chat AI", + "launcher_title": "Apri Chat AI", + "new_chat": "Inizia una nuova chat", + "save_chat": "Salva la chat negli appunti", + "empty_state": "Avvia una conversazione", + "history": "Cronologia delle chat", + "recent_chats": "Conversazioni recenti", + "no_chats": "Nessuna conversazione precedente" + }, + "llm": { + "settings_title": "AI / LLM", + "settings_description": "Configurare le integrazioni con l'intelligenza artificiale e i modelli linguistici di grandi dimensioni.", + "add_provider": "Aggiungi fornitore", + "add_provider_title": "Aggiungi un fornitore di IA", + "configured_providers": "Fornitori configurati", + "no_providers_configured": "Non sono stati ancora configurati fornitori.", + "provider_name": "Nome", + "provider_type": "Fornitore", + "actions": "Azioni", + "delete_provider": "Elimina", + "delete_provider_confirmation": "Sei sicuro di voler eliminare il provider \"{{name}}\"?", + "api_key": "Chiave API", + "api_key_placeholder": "Inserisci la tua chiave API", + "cancel": "Annulla" } } 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/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 46d83a5614..6bae391f56 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -104,7 +104,7 @@ export interface SavedData { export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, onContentChange, dataSaved, updateInterval }: { noteType: NoteType; - note: FNote, + note: FNote | null | undefined, noteContext: NoteContext | null | undefined, getData: () => Promise | SavedData | undefined, onContentChange: (newContent: string) => void, @@ -118,8 +118,8 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on return async () => { const data = await getData(); - // for read only notes - if (data === undefined || note.type !== noteType) return; + // for read only notes, or if note is not yet available (e.g. lazy creation) + if (data === undefined || !note || note.type !== noteType) return; protected_session_holder.touchProtectedSessionIfNecessary(note); @@ -138,7 +138,7 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on // React to note/blob changes. useEffect(() => { - if (!blob) return; + if (!blob || !note) return; noteSavedDataStore.set(note.noteId, blob.content); spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content)); }, [ blob ]); 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 && (