Compare commits

..

11 Commits

118 changed files with 1565 additions and 5876 deletions

2
.nvmrc
View File

@@ -1 +1 @@
24.14.1
24.14.0

View File

@@ -125,15 +125,6 @@ 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

View File

@@ -14,15 +14,15 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.33.0",
"packageManager": "pnpm@10.32.1",
"devDependencies": {
"@redocly/cli": "2.25.2",
"@redocly/cli": "2.24.0",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"typedoc": "0.28.18",
"typedoc": "0.28.17",
"typedoc-plugin-missing-exports": "4.1.2"
}
}

View File

@@ -28,7 +28,7 @@
"@mermaid-js/layout-elk": "0.2.1",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.9.0",
"@preact/signals": "2.8.2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
@@ -43,33 +43,32 @@
"@univerjs/preset-sheets-note": "0.18.0",
"@univerjs/preset-sheets-sort": "0.18.0",
"@univerjs/presets": "0.18.0",
"@zumer/snapdom": "2.6.0",
"@zumer/snapdom": "2.5.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",
"i18next": "25.10.10",
"i18next": "25.8.18",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.44",
"katex": "0.16.38",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.5",
"marked": "17.0.4",
"mermaid": "11.13.0",
"mind-elixir": "5.9.3",
"normalize.css": "8.0.1",
"panzoom": "9.4.4",
"panzoom": "9.4.3",
"preact": "10.29.0",
"react-i18next": "17.0.0",
"react-i18next": "16.5.8",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"rrule": "2.8.1",
@@ -87,9 +86,9 @@
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.8.9",
"happy-dom": "20.8.4",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.4.0"
"vite-plugin-static-copy": "3.3.0"
}
}

View File

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

View File

@@ -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" | "llmChat";
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
export interface NotePathRecord {
isArchived: boolean;

View File

@@ -84,55 +84,6 @@ async function createSearchNote(opts = {}) {
return await froca.getNote(note.noteId);
}
async function createLlmChat() {
const note = await server.post<FNoteRow>("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<FNoteRow | null>("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<FNoteRow>("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<RecentLlmChat[]> {
return await server.get<RecentLlmChat[]>(`special-notes/recent-llm-chats?limit=${limit}`);
}
export default {
getInboxNote,
getTodayNote,
@@ -143,9 +94,5 @@ export default {
getMonthNote,
getYearNote,
createSqlConsole,
createSearchNote,
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats
createSearchNote
};

View File

@@ -13,11 +13,6 @@ 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[];

View File

@@ -19,8 +19,7 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
search: null,
text: null,
webView: null,
spreadsheet: null,
llmChat: null
spreadsheet: null
};
export const byBookType: Record<ViewTypeOptions, string | null> = {

View File

@@ -1,110 +0,0 @@
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<LlmModelInfo[]> {
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<string, unknown>) => 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<void> {
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();
}
}

View File

@@ -1,7 +1,6 @@
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";
@@ -42,7 +41,6 @@ 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" },
@@ -94,7 +92,6 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandNames>[] {
return NOTE_TYPES
.filter((nt) => !nt.reserved && nt.type !== "book")
.filter((nt) => nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))
.map((nt) => {
const menuItem: MenuCommandItem<TreeCommandNames> = {
title: nt.title,

View File

@@ -922,7 +922,6 @@ export default {
parseDate,
formatDateISO,
formatDateTime,
formatTime,
formatTimeInterval,
formatSize,
localNowDateTime,

View File

@@ -17,6 +17,9 @@ class SetupController {
private syncServerHostInput: HTMLInputElement;
private syncProxyInput: HTMLInputElement;
private passwordInput: HTMLInputElement;
private totpTokenInput: HTMLInputElement;
private totpSection: HTMLElement;
private totpEnabled = false;
private sections: Record<SetupStep, HTMLElement>;
constructor(rootNode: HTMLElement, syncInProgress: boolean) {
@@ -29,6 +32,8 @@ class SetupController {
this.syncServerHostInput = mustGetElement("sync-server-host", HTMLInputElement);
this.syncProxyInput = mustGetElement("sync-proxy", HTMLInputElement);
this.passwordInput = mustGetElement("password", HTMLInputElement);
this.totpTokenInput = mustGetElement("totp-token", HTMLInputElement);
this.totpSection = mustGetElement("totp-section", HTMLElement);
this.sections = {
"setup-type": mustGetElement("setup-type-section", HTMLElement),
"new-document-in-progress": mustGetElement("new-document-in-progress-section", HTMLElement),
@@ -56,6 +61,10 @@ class SetupController {
});
}
this.syncServerHostInput.addEventListener("blur", () => {
void this.checkTotpStatus();
});
for (const backButton of document.querySelectorAll<HTMLElement>("[data-action='back']")) {
backButton.addEventListener("click", () => {
this.back();
@@ -87,9 +96,40 @@ class SetupController {
}
}
private async checkTotpStatus() {
const syncServerHost = this.syncServerHostInput.value.trim();
if (!syncServerHost) {
this.setTotpEnabled(false);
return;
}
try {
const resp = await $.post("api/setup/check-server-totp", {
syncServerHost
});
this.setTotpEnabled(!!resp.totpEnabled);
} catch {
// If we can't reach the server, don't show the TOTP field yet.
this.setTotpEnabled(false);
}
}
private setTotpEnabled(enabled: boolean) {
this.totpEnabled = enabled;
if (!enabled) {
this.totpTokenInput.value = "";
}
this.render();
}
private back() {
this.setStep("setup-type");
this.setupType = "";
this.setTotpEnabled(false);
for (const input of this.setupTypeInputs) {
input.checked = false;
@@ -113,11 +153,21 @@ class SetupController {
return;
}
await this.checkTotpStatus();
const totpToken = this.totpTokenInput.value.trim();
if (this.totpEnabled && !totpToken) {
showAlert("TOTP token can't be empty when two-factor authentication is enabled");
return;
}
// not using server.js because it loads too many dependencies
const resp = await $.post("api/setup/sync-from-server", {
syncServerHost,
syncProxy,
password
password,
totpToken
});
if (resp.result === "success") {
@@ -139,13 +189,10 @@ class SetupController {
section.style.display = step === this.step ? "" : "none";
}
this.totpSection.style.display = this.totpEnabled ? "" : "none";
this.setupTypeNextButton.disabled = !this.setupType;
}
private getSelectedSetupType(): SetupType {
return (this.setupTypeInputs.find((input) => input.checked)?.value ?? "") as SetupType;
}
private startSyncPolling() {
if (this.syncPollIntervalId !== null) {
return;
@@ -196,7 +243,7 @@ function mustGetElement<T extends typeof HTMLElement>(id: string, ctor: T): Inst
return element as InstanceType<T>;
}
addEventListener("DOMContentLoaded", (event) => {
addEventListener("DOMContentLoaded", () => {
const rootNode = document.getElementById("setup-dialog");
if (!rootNode || !(rootNode instanceof HTMLElement)) return;

View File

@@ -1750,11 +1750,8 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
justify-content: space-between;
align-items: baseline;
font-weight: bold;
color: var(--muted-text-color) !important;
}
#right-pane .card-header-title {
text-transform: uppercase;
color: var(--muted-text-color) !important;
}
#right-pane .card-header-buttons {

View File

@@ -93,10 +93,7 @@
"digits": "dígits",
"inheritable": "Heretable",
"delete": "Suprimeix",
"color_type": "Color",
"textarea": "Text multi linia",
"date_time": "Data i hora",
"precision_title": "Quants dígits han d'estar disponibles per a coma flotant a la interfície de configuració."
"color_type": "Color"
},
"rename_label": {
"to": "Per"

View File

@@ -446,8 +446,7 @@
"and_more": "... 以及另外 {{count}} 个。",
"print_landscape": "导出为 PDF 时,将页面方向更改为横向而不是纵向。",
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
"color_type": "颜色",
"textarea": "多行文本"
"color_type": "颜色"
},
"attribute_editor": {
"help_text_body1": "要添加标签,只需输入例如 <code>#rock</code> 或者如果您还想添加值,则例如 <code>#year = 2020</code>",
@@ -2168,52 +2167,5 @@
},
"setup_form": {
"more_info": "了解更多"
},
"media": {
"play": "播放(空格)",
"pause": "暂停(空格)",
"back-10s": "后退10秒左箭头键",
"forward-30s": "前进30秒",
"mute": "静音M",
"unmute": "取消静音M",
"playback-speed": "播放速度",
"loop": "循环播放",
"disable-loop": "禁用循环播放",
"rotate": "旋转",
"picture-in-picture": "画中画",
"exit-picture-in-picture": "退出画中画",
"fullscreen": "全屏F",
"exit-fullscreen": "退出全屏",
"unsupported-format": "此文件格式不支持媒体预览:\n{{mime}}",
"zoom-to-fit": "缩放以填充",
"zoom-reset": "重置缩放以填充"
},
"mermaid": {
"sample_diagrams": "示例图:",
"sample_flowchart": "流程图",
"sample_class": "类图",
"sample_sequence": "时序图",
"sample_entity_relationship": "实体关系图",
"sample_state": "状态图",
"sample_mindmap": "思维导图",
"sample_architecture": "架构图",
"sample_block": "模块图",
"sample_c4": "C4 图",
"sample_gantt": "甘特图",
"sample_git": "Git 流程图",
"sample_kanban": "看板图",
"sample_packet": "数据包图",
"sample_pie": "饼图",
"sample_quadrant": "象限图",
"sample_radar": "雷达图",
"sample_requirement": "需求图",
"sample_sankey": "桑基图",
"sample_timeline": "时间轴图",
"sample_treemap": "树形图",
"sample_user_journey": "用户旅程图",
"sample_xy": "散点图",
"sample_venn": "韦恩图",
"sample_ishikawa": "鱼骨图",
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
}
}

View File

@@ -446,8 +446,7 @@
"and_more": "... und {{count}} mehr.",
"print_landscape": "Beim Export als PDF, wird die Seitenausrichtung Querformat anstatt Hochformat verwendet.",
"print_page_size": "Beim Export als PDF, wird die Größe der Seite angepasst. Unterstützte Größen: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Farbe",
"textarea": "Mehrzeilen-Text"
"color_type": "Farbe"
},
"attribute_editor": {
"help_text_body1": "Um ein Label hinzuzufügen, gebe einfach z.B. ein. <code>#rock</code> oder wenn du auch einen Wert hinzufügen möchten, dann z.B. <code>#year = 2024</code>",

View File

@@ -1,6 +1,6 @@
{
"about": {
"title": "Σχετικά με το Trilium Notes",
"title": "Πληροφορίες για το Trilium Notes",
"homepage": "Αρχική Σελίδα:",
"app_version": "Έκδοση εφαρμογής:",
"db_version": "Έκδοση βάσης δεδομένων:",

View File

@@ -1157,9 +1157,7 @@
"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.",
"llm_name": "AI / LLM Chat",
"llm_description": "Enable the AI chat sidebar and LLM chat notes powered by large language models."
"new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases."
},
"fonts": {
"theme_defined": "Theme defined",
@@ -1601,7 +1599,6 @@
"geo-map": "Geo Map",
"beta-feature": "Beta",
"ai-chat": "AI Chat",
"llm-chat": "AI Chat",
"task-list": "Task List",
"new-feature": "New",
"collections": "Collections",
@@ -1613,49 +1610,6 @@
"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",
@@ -2276,21 +2230,5 @@
"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"
}
}

View File

@@ -477,8 +477,7 @@
"and_more": "... agus {{count}} eile.",
"print_landscape": "Agus é á onnmhairiú go PDF, athraítear treoshuíomh an leathanaigh go tírdhreach seachas portráid.",
"print_page_size": "Agus é á easpórtáil go PDF, athraítear méid an leathanaigh. Luachanna tacaithe: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Dath",
"textarea": "Téacs Il-líne"
"color_type": "Dath"
},
"attribute_editor": {
"help_text_body1": "Chun lipéad a chur leis, clóscríobh m.sh. <code>#rock</code> nó más mian leat luach a chur leis freisin ansin m.sh. <code>#year = 2020</code>",

View File

@@ -520,7 +520,7 @@
"custom_name_label": "Nome del motore di ricerca personalizzato",
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
"custom_url_placeholder": "Personalizza indirizzo url del motore di ricerca"
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
},
"sql_table_schemas": {
"tables": "Tabelle"
@@ -917,8 +917,7 @@
"print_landscape": "Quando si esporta in PDF, cambia l'orientamento della pagina da verticale a orizzontale.",
"print_page_size": "Quando si esporta in PDF, modifica le dimensioni della pagina. Valori supportati: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Colore",
"share_root": "segna la nota che viene servita su /share root.",
"textarea": "Testo su più righe"
"share_root": "segna la nota che viene servita su /share root."
},
"attribute_editor": {
"help_text_body1": "Per aggiungere un'etichetta, basta digitare ad esempio <code>#rock</code> oppure, se si desidera aggiungere anche un valore, ad esempio <code>#year = 2020</code>",
@@ -2198,52 +2197,5 @@
},
"setup_form": {
"more_info": "Per saperne di più"
},
"media": {
"play": "Gioca (Barra spaziatrice)",
"pause": "Pausa (Barra spaziatrice)",
"back-10s": "Indietro di 10 (tasto freccia sinistra)",
"forward-30s": "Avanti 30s",
"mute": "Muto (M)",
"unmute": "Riattiva audio (M)",
"playback-speed": "Velocità di riproduzione",
"loop": "Ciclo",
"disable-loop": "Disattiva il ciclo",
"rotate": "Ruota",
"picture-in-picture": "Immagine nell'immagine",
"exit-picture-in-picture": "Esci dalla modalità picture-in-picture",
"fullscreen": "Schermo intero (F)",
"exit-fullscreen": "Esci dalla modalità a schermo intero",
"unsupported-format": "Per questo formato di file non è disponibile l'anteprima multimediale:\n{{mime}}",
"zoom-to-fit": "Ingrandisci per riempire",
"zoom-reset": "Ripristina lo zoom a schermo intero"
},
"mermaid": {
"placeholder": "Digita il contenuto del tuo diagramma Mermaid oppure utilizza uno dei diagrammi di esempio riportati di seguito.",
"sample_diagrams": "Esempi di diagrammi:",
"sample_flowchart": "Diagramma di flusso",
"sample_class": "Classe",
"sample_sequence": "Sequenza",
"sample_entity_relationship": "Relazioni tra entità",
"sample_state": "Stato",
"sample_mindmap": "Mappa mentale",
"sample_architecture": "Architettura",
"sample_block": "Blocco",
"sample_c4": "C4",
"sample_gantt": "Gantt",
"sample_git": "Git",
"sample_kanban": "Kanban",
"sample_packet": "Packet",
"sample_pie": "Torta",
"sample_quadrant": "Quadrante",
"sample_radar": "Radar",
"sample_requirement": "Requisito",
"sample_sankey": "Chiave",
"sample_timeline": "Cronologia",
"sample_treemap": "Treemap",
"sample_user_journey": "Percorso dell'utente",
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
}
}

View File

@@ -1180,8 +1180,7 @@
"is_owned_by_note": "ノートによって所有されています",
"and_more": "...その他 {{count}} 件。",
"print_landscape": "PDF にエクスポートするときに、ページの向きを縦向きではなく横向きに変更します。",
"print_page_size": "PDF にエクスポートするときに、ページのサイズを変更します。サポートされる値: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>。",
"textarea": "複数行テキスト"
"print_page_size": "PDF にエクスポートするときに、ページのサイズを変更します。サポートされる値: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>。"
},
"link_context_menu": {
"open_note_in_popup": "クイック編集",

View File

@@ -117,7 +117,7 @@
"no_path_to_clone_to": "Brak ścieżki do sklonowania.",
"note_cloned": "Notatka \"{{clonedTitle}}\" została sklonowana do \"{{targetTitle}}\"",
"help_on_links": "Pomoc dotycząca linków",
"target_parent_note": "Docelowa notatka pierwotna"
"target_parent_note": "Docelowa notatka nadrzędna"
},
"help": {
"title": "Ściągawka",
@@ -126,7 +126,7 @@
"collapseExpand": "zwiń/rozwiń węzeł",
"notSet": "nie ustawiono",
"goBackForwards": "idź wstecz / do przodu w historii",
"showJumpToNoteDialog": "pokaż <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Przejdź do\"</a>",
"showJumpToNoteDialog": "pokaż okno <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Przejdź do\"</a>",
"scrollToActiveNote": "przewiń do aktywnej notatki",
"jumpToParentNote": "przejdź do notatki nadrzędnej",
"collapseWholeTree": "zwiń całe drzewo notatek",
@@ -402,8 +402,7 @@
"and_more": "... i {{count}} więcej.",
"print_landscape": "Podczas eksportowania do PDF zmienia orientację strony na poziomą zamiast pionowej.",
"print_page_size": "Podczas eksportowania do PDF zmienia rozmiar strony. Obsługiwane wartości: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Kolor",
"textarea": "Wiele linii tekstu"
"color_type": "Kolor"
},
"import": {
"importIntoNote": "Importuj do notatki",
@@ -1614,7 +1613,7 @@
"password_changed_success": "Hasło zostało zmienione. Trilium zostanie przeładowane po naciśnięciu OK."
},
"multi_factor_authentication": {
"title": "Uwierzytelnianie wieloskładnikowe",
"title": "Uwierzytelnianie wieloskładnikowe (MFA)",
"description": "Uwierzytelnianie wieloskładnikowe (MFA) dodaje dodatkową warstwę zabezpieczeń do Twojego konta. Zamiast tylko wpisywać hasło do logowania, MFA wymaga podania jednego lub więcej dodatkowych dowodów tożsamości. W ten sposób, nawet jeśli ktoś zdobędzie Twoje hasło, nadal nie będzie mógł uzyskać dostępu do Twojego konta bez drugiej informacji. To jak dodanie dodatkowego zamka do drzwi, utrudniającego włamanie.<br><br>Proszę postępować zgodnie z poniższymi instrukcjami, aby włączyć MFA. Jeśli nie skonfigurujesz poprawnie, logowanie powróci do samego hasła.",
"mfa_enabled": "Włącz uwierzytelnianie wieloskładnikowe",
"mfa_method": "Metoda MFA",
@@ -1629,7 +1628,7 @@
"totp_secret_generated": "Sekret TOTP wygenerowany",
"totp_secret_warning": "Proszę zapisać wygenerowany sekret w bezpiecznym miejscu. Nie zostanie pokazany ponownie.",
"totp_secret_regenerate_confirm": "Czy na pewno chcesz ponownie wygenerować sekret TOTP? To unieważni poprzedni sekret TOTP i wszystkie istniejące kody odzyskiwania.",
"recovery_keys_title": "Klucze odzyskiwania logowania jednokrotnego",
"recovery_keys_title": "Klucze odzyskiwania logowania jednokrotnego (SSO)",
"recovery_keys_description": "Klucze odzyskiwania logowania jednokrotnego służą do logowania w przypadku braku dostępu do kodów Authenticator.",
"recovery_keys_description_warning": "Klucze odzyskiwania nie zostaną pokazane ponownie po opuszczeniu strony, przechowuj je w bezpiecznym miejscu.<br>Po użyciu klucza odzyskiwania nie można go użyć ponownie.",
"recovery_keys_error": "Błąd generowania kodów odzyskiwania",
@@ -1767,7 +1766,7 @@
"book": "Kolekcja",
"mermaid-diagram": "Diagram Mermaid",
"canvas": "Płótno",
"web-view": "Widok strony web",
"web-view": "Widok WWW",
"mind-map": "Mapa myśli",
"file": "Plik",
"image": "Obraz",
@@ -1816,9 +1815,9 @@
"modal_title": "Konfiguracja listy wyróżnień",
"menu_configure": "Konfiguracja listy wyróżnień...",
"no_highlights": "Nie znaleziono wyróżnień.",
"title_with_count_one": "{{count}} wyróżnienie",
"title_with_count_few": "{{count}} wyróżnienia",
"title_with_count_many": "{{count}} wyróżnień"
"title_with_count_one": "{{count}} podświetlenie",
"title_with_count_few": "{{count}} podświetlenia",
"title_with_count_many": "{{count}} podświetleń"
},
"quick-search": {
"placeholder": "Szybkie wyszukiwanie",
@@ -2071,7 +2070,7 @@
"read_only_temporarily_disabled_description": "Ta notatka jest obecnie edytowalna, ale normalnie jest tylko do odczytu. Notatka powróci do trybu tylko do odczytu, gdy tylko przejdziesz do innej notatki.\n\nKliknij, aby ponownie włączyć tryb tylko do odczytu.",
"shared_publicly": "Udostępniona publicznie",
"shared_locally": "Udostępniona lokalnie",
"clipped_note": "Wycinek z sieci",
"clipped_note": "Wycinek WWW",
"clipped_note_description": "Ta notatka została pierwotnie pobrana z {{url}}.\n\nKliknij, aby przejść do źródłowej strony internetowej.",
"execute_script": "Uruchom skrypt",
"execute_script_description": "Ta notatka jest notatką skryptową. Kliknij, aby wykonać skrypt.",
@@ -2237,7 +2236,7 @@
"sample_c4": "C4",
"sample_gantt": "Wykres Gantta",
"sample_git": "Diagram Git",
"sample_kanban": "Tablica Kanban",
"sample_kanban": "Kanban",
"sample_packet": "Diagram pakietów",
"sample_pie": "Wykres kołowy",
"sample_quadrant": "Diagram kwadrantowy",

View File

@@ -446,8 +446,7 @@
"app_theme_base": "設定為 \"next\"、\"next-light \" 或 \"next-dark\",以使用相應的 TriliumNext 主題(自動、淺色或深色)作為自訂主題的基礎,而非傳統主題。",
"print_landscape": "匯出為 PDF 時,將頁面方向更改為橫向而非縱向。",
"print_page_size": "在匯出 PDF 時更改頁面大小。支援的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
"color_type": "顏色",
"textarea": "多行文字"
"color_type": "顏色"
},
"attribute_editor": {
"help_text_body1": "要新增標籤,只需輸入例如 <code>#rock</code> 或者如果您還想新增值,則例如 <code>#year = 2020</code>",
@@ -2183,52 +2182,5 @@
},
"setup_form": {
"more_info": "了解更多"
},
"media": {
"play": "播放 (空白鍵)",
"pause": "暫停 (空白鍵)",
"back-10s": "往前 10 秒 (左方向鍵)",
"forward-30s": "往後 30 秒",
"mute": "靜音 (M)",
"unmute": "解除靜音 (M)",
"playback-speed": "播放速度",
"loop": "循環",
"disable-loop": "解除循環",
"rotate": "旋轉",
"picture-in-picture": "畫中畫",
"exit-picture-in-picture": "退出畫中畫",
"fullscreen": "全螢幕 (F)",
"exit-fullscreen": "退出全螢幕",
"unsupported-format": "此檔案格式不支援媒體預覽:\n{{mime}}",
"zoom-to-fit": "放大至填滿畫面",
"zoom-reset": "重設放大至填滿畫面"
},
"mermaid": {
"placeholder": "請輸入您的美人魚圖表內容,或選用下方其中一個範例圖表。",
"sample_diagrams": "範例圖表:",
"sample_flowchart": "流程圖",
"sample_class": "階層圖",
"sample_sequence": "時序圖",
"sample_entity_relationship": "實體關係圖",
"sample_state": "狀態圖",
"sample_mindmap": "心智圖",
"sample_architecture": "架構圖",
"sample_block": "區塊圖",
"sample_c4": "C4 圖",
"sample_gantt": "甘特圖",
"sample_git": "Git 分支圖",
"sample_kanban": "看板圖",
"sample_packet": "數據包圖",
"sample_pie": "圓餅圖",
"sample_quadrant": "象限圖",
"sample_radar": "雷達圖",
"sample_requirement": "需求圖",
"sample_sankey": "桑基圖",
"sample_timeline": "時間軸",
"sample_treemap": "樹狀圖",
"sample_user_journey": "使用者旅程",
"sample_xy": "XY 圖表",
"sample_venn": "韋恩圖",
"sample_ishikawa": "魚骨圖"
}
}

View File

@@ -1,18 +1,18 @@
import appContext from "../../components/app_context.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import type { Attribute } from "../../services/attribute_parser.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import froca from "../../services/froca.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import linkService from "../../services/link.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
import server from "../../services/server.js";
import shortcutService from "../../services/shortcuts.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import SpacedUpdate from "../../services/spaced_update.js";
import utils from "../../services/utils.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import shortcutService from "../../services/shortcuts.js";
import appContext from "../../components/app_context.js";
import type { Attribute } from "../../services/attribute_parser.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
const TPL = /*html*/`
<div class="attr-detail tn-tool-dialog">
@@ -29,7 +29,6 @@ const TPL = /*html*/`
max-height: 600px;
overflow: auto;
box-shadow: 10px 10px 93px -25px black;
contain: none;
}
.attr-help td {
@@ -344,7 +343,6 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
private $relatedNotesList!: JQuery<HTMLElement>;
private $relatedNotesMoreNotes!: JQuery<HTMLElement>;
private $attrHelp!: JQuery<HTMLElement>;
private $statusBar?: JQuery<HTMLElement>;
private relatedNotesSpacedUpdate!: SpacedUpdate;
private attribute!: Attribute;
@@ -579,24 +577,17 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
return;
}
if (isNewLayout) {
if (!this.$statusBar) {
this.$statusBar = $(document.body).find(".component.status-bar");
}
this.$widget
.css("left", detPosition.left)
.css("right", detPosition.right)
.css("top", y - offset.top + 70)
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
const statusBarHeight = this.$statusBar.outerHeight() ?? 0;
const maxHeight = document.body.clientHeight - statusBarHeight;
if (isNewLayout) {
this.$widget
.css("left", offset.left + (typeof detPosition.left === "number" ? detPosition.left : 0))
.css("top", "unset")
.css("bottom", statusBarHeight ?? 0)
.css("max-height", maxHeight);
} else {
this.$widget
.css("left", detPosition.left)
.css("right", detPosition.right)
.css("top", y - offset.top + 70)
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
.css("bottom", 70)
.css("max-height", "80vh");
}
if (focus === "name") {
@@ -704,14 +695,14 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
return "label-definition";
} else if (attribute.name.startsWith("relation:")) {
return "relation-definition";
} else {
return "label";
}
return "label";
} else if (attribute.type === "relation") {
return "relation";
} else {
this.$title.text("");
}
this.$title.text("");
}
updateAttributeInEditor() {

View File

@@ -364,19 +364,23 @@
mask-repeat: no-repeat;
mask-size: 100% 100%;
}
.ck-content p {
margin-bottom: 0.5em;
line-height: 1.3;
}
.ck-content figure.image {
width: 25%;
}
.ck-content .table {
display: flex;
flex-direction: column-reverse;
overflow-x: scroll;
--scrollbar-thickness: 0;
scrollbar-width: none;
table {
width: max-content;
table-layout: auto;
@@ -431,4 +435,4 @@
}
}
/* #endregion */
/* #endregion */

View File

@@ -1,7 +1,6 @@
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";
@@ -13,7 +12,6 @@ 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";
@@ -100,8 +98,6 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
return <QuickSearchLauncherWidget />;
case "mobileTabSwitcher":
return <TabSwitcher />;
case "sidebarChat":
return isExperimentalFeatureEnabled("llm") ? <SidebarChatButton /> : undefined;
default:
console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}

View File

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

View File

@@ -5,7 +5,6 @@ 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";
@@ -29,7 +28,6 @@ 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 {

View File

@@ -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<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole";
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
@@ -147,11 +147,5 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
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
}
};

View File

@@ -5,27 +5,16 @@ interface FormDropdownList<T> extends Omit<DropdownProps, "children"> {
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<T>({ values, keyProperty, titleProperty, titleSuffixProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
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} <small>{suffix}</small></>;
}
return title;
};
return (
<Dropdown text={currentValueData ? renderTitle(currentValueData) : ""} {...restProps}>
<Dropdown text={currentValueData?.[titleProperty] ?? ""} {...restProps}>
{values.map(item => (
<FormListItem
onClick={() => onChange(item[keyProperty] as string)}
@@ -33,9 +22,9 @@ export default function FormDropdownList<T>({ values, keyProperty, titleProperty
description={descriptionProperty && item[descriptionProperty] as string}
selected={currentValue === item[keyProperty]}
>
{renderTitle(item)}
{item[titleProperty] as string}
</FormListItem>
))}
</Dropdown>
)
}
}

View File

@@ -1,4 +1,3 @@
import DOMPurify from "dompurify";
import type { CSSProperties, HTMLProps, RefObject } from "preact/compat";
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
@@ -15,16 +14,16 @@ export default function RawHtml({containerRef, ...props}: RawHtmlProps & { conta
}
export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLDivElement>}) {
return <div ref={containerRef} {...getProps(props)} />;
return <div ref={containerRef} {...getProps(props)} />
}
function getProps({ className, html, style, onClick }: RawHtmlProps) {
return {
className,
className: className,
dangerouslySetInnerHTML: getHtml(html ?? ""),
style,
onClick
};
}
}
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
@@ -40,19 +39,3 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
__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 (
<div
className={className}
style={style}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
/>
);
}

View File

@@ -7,7 +7,6 @@ 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";
@@ -73,7 +72,7 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note
noCodeNotes?: boolean;
}) {
const mimeTypes = useMimeTypes();
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static && (nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))), []);
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static), []);
const changeNoteType = useCallback(async (type: NoteType, mime?: string) => {
if (!note || (type === currentNoteType && mime === currentNoteMime)) {
return;

View File

@@ -1,4 +1,3 @@
import { createPortal } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import FAttribute from "../../entities/fattribute";
@@ -75,7 +74,7 @@ export default function InheritedAttributesTab({ note, componentId, emptyListStr
)}
</div>
{createPortal(attributeDetailWidgetEl, document.body)}
{attributeDetailWidgetEl}
</div>
);
}

View File

@@ -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", "llmChat"].includes(noteType);
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType);
const isSearchOrBook = ["search", "book"].includes(noteType);
const isHelpPage = note.noteId.startsWith("_help");
const [syncServerHost] = useTriliumOption("syncServerHost");

View File

@@ -1,6 +1,5 @@
import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5";
import { AttributeType } from "@triliumnext/commons";
import { createPortal } from "preact/compat";
import { MutableRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "preact/hooks";
import type { CommandData, FilteredCommandNames } from "../../../components/app_context";
@@ -337,8 +336,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
let matchedAttr: Attribute | null = null;
for (const attr of parsedAttrs) {
if (attr.startIndex !== undefined && clickIndex > attr.startIndex &&
attr.endIndex !== undefined && clickIndex <= attr.endIndex) {
if (attr.startIndex && clickIndex > attr.startIndex && attr.endIndex && clickIndex <= attr.endIndex) {
matchedAttr = attr;
break;
}
@@ -409,7 +407,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
)}
</div>}
{createPortal(attributeDetailWidgetEl, document.body)}
{attributeDetailWidgetEl}
</>
);
}

View File

@@ -7,7 +7,6 @@ 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";
@@ -20,7 +19,6 @@ 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;
@@ -93,11 +91,6 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) {
el: <HighlightsList />,
enabled: noteType === "text" && highlightsList.length > 0,
},
{
el: <SidebarChat />,
enabled: noteType !== "llmChat" && isExperimentalFeatureEnabled("llm"),
position: 1000
},
...widgetsByParent.getLegacyWidgets("right-pane").map((widget) => ({
el: <CustomLegacyWidget key={widget._noteId} originalWidget={widget as LegacyRightPanelWidget} />,
enabled: true,

View File

@@ -51,7 +51,7 @@ export default function RightPanelWidget({ id, title, buttons, children, contain
>
<ActionButton icon="bx bx-chevron-down" text="" />
<div class="card-header-title">{title}</div>
<div class="card-header-buttons" onClick={e => e.stopPropagation()}>
<div class="card-header-buttons">
{buttons}
{contextMenuItems && (
<ActionButton

View File

@@ -1,113 +0,0 @@
/* Sidebar Chat Widget Styles */
.sidebar-chat-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0; /* Allow shrinking in flex context */
overflow: hidden; /* Contain children within available space */
}
.sidebar-chat-container .llm-chat-input-form {
flex-shrink: 0; /* Keep input bar from shrinking */
.llm-chat-input {
font-size: 0.9em;
padding: 0.5em;
}
}
.sidebar-chat-messages {
flex: 1;
min-height: 0; /* Allow flex shrinking for scroll containment */
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Reuse llm-chat-message styles but make them more compact */
.sidebar-chat-messages .llm-chat-message-wrapper {
margin-top: 0;
max-width: 100%;
}
.sidebar-chat-messages .llm-chat-message {
padding: 0.5rem 0.75rem;
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%;
}
/* Make the sidebar chat widget grow to fill available space when expanded */
#right-pane .widget.grow:not(.collapsed) {
flex: 1;
flex-shrink: 1; /* Override flex-shrink: 0 from main styles */
min-height: 0;
display: flex;
flex-direction: column;
}
#right-pane .widget.grow:not(.collapsed) .body-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden; /* Override overflow: auto from main styles */
}
#right-pane .widget.grow:not(.collapsed) .card-body {
flex: 1;
min-height: 0;
overflow: hidden; /* Override overflow: auto - let child handle scrolling */
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;
}
.sidebar-chat-history-item-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.sidebar-chat-history-item-content span,
.sidebar-chat-history-item-content strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-chat-history-date {
font-size: 0.75rem;
color: var(--muted-text-color);
margin-top: 0.125rem;
}

View File

@@ -1,335 +0,0 @@
import "./SidebarChat.css";
import type { Dropdown as BootstrapDropdown } from "bootstrap";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import dateNoteService, { type RecentLlmChat } from "../../services/date_notes.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import { formatDateTime } from "../../utils/formatters";
import ActionButton from "../react/ActionButton.js";
import Dropdown from "../react/Dropdown.js";
import { FormListItem } from "../react/FormList.js";
import { useActiveNoteContext, useNote, useNoteProperty } from "../react/hooks.js";
import NoItems from "../react/NoItems.js";
import ChatInputBar from "../type_widgets/llm_chat/ChatInputBar.js";
import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js";
import type { LlmChatContent } from "../type_widgets/llm_chat/llm_chat_types.js";
import { useLlmChat } from "../type_widgets/llm_chat/useLlmChat.js";
import RightPanelWidget from "./RightPanelWidget.js";
/**
* Sidebar chat widget that appears in the right panel.
* Uses a hidden LLM chat note for persistence across all notes.
* The same chat persists when switching between notes.
*/
export default function SidebarChat() {
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
const [shouldSave, setShouldSave] = useState(false);
const [recentChats, setRecentChats] = useState<RecentLlmChat[]>([]);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const historyDropdownRef = useRef<BootstrapDropdown | null>(null);
// Get the current active note context
const { noteId: activeNoteId, note: activeNote } = useActiveNoteContext();
// Reactively watch the chat note's title (updates via WebSocket sync after auto-rename)
const chatNote = useNote(chatNoteId);
const chatTitle = useNoteProperty(chatNote, "title") || t("sidebar_chat.title");
// Use shared chat hook with sidebar-specific options
const chat = useLlmChat(
// onMessagesChange - trigger save
() => setShouldSave(true),
{ defaultEnableNoteTools: true, supportsExtendedThinking: true }
);
// Update chat context when active note changes
useEffect(() => {
chat.setContextNoteId(activeNoteId ?? undefined);
}, [activeNoteId, chat.setContextNoteId]);
// Sync chatNoteId into the hook for auto-title generation
useEffect(() => {
chat.setChatNoteId(chatNoteId ?? undefined);
}, [chatNoteId, chat.setChatNoteId]);
// Ref to access chat methods in effects without triggering re-runs
const chatRef = useRef(chat);
chatRef.current = chat;
// Handle debounced save when shouldSave is triggered
useEffect(() => {
if (!shouldSave || !chatNoteId) {
setShouldSave(false);
return;
}
setShouldSave(false);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(async () => {
const content = chat.getContent();
try {
await server.put(`notes/${chatNoteId}/data`, {
content: JSON.stringify(content)
});
} catch (err) {
console.error("Failed to save chat:", err);
}
}, 500);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = undefined;
}
};
}, [shouldSave, chatNoteId, chat]);
// Load the most recent chat on mount (runs once)
useEffect(() => {
let cancelled = false;
const loadMostRecentChat = async () => {
try {
const existingChat = await dateNoteService.getMostRecentLlmChat();
if (cancelled) return;
if (existingChat) {
setChatNoteId(existingChat.noteId);
// Load content inline to avoid dependency issues
try {
const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`);
if (!cancelled && blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
chatRef.current.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load chat content:", err);
}
} else {
// No existing chat - will create on first message
setChatNoteId(null);
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to load sidebar chat:", err);
}
};
loadMostRecentChat();
return () => {
cancelled = true;
};
}, []);
// Custom submit handler that ensures chat note exists first
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!chat.input.trim() || chat.isStreaming) return;
// Ensure chat note exists before sending (lazy creation)
let noteId = chatNoteId;
if (!noteId) {
try {
const note = await dateNoteService.getOrCreateLlmChat();
if (note) {
setChatNoteId(note.noteId);
noteId = note.noteId;
}
} catch (err) {
console.error("Failed to create sidebar chat:", err);
return;
}
}
if (!noteId) {
console.error("Cannot send message: no chat note available");
return;
}
// Ensure the hook has the chatNoteId before submitting (state update from
// setChatNoteId above won't be visible until next render)
chat.setChatNoteId(noteId);
// Delegate to shared handler
await chat.handleSubmit(e);
}, [chatNoteId, chat]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [handleSubmit]);
const handleNewChat = useCallback(async () => {
try {
const note = await dateNoteService.createLlmChat();
if (note) {
setChatNoteId(note.noteId);
chat.clearMessages();
}
} catch (err) {
console.error("Failed to create new chat:", err);
}
}, [chat]);
const handleSaveChat = useCallback(async () => {
if (!chatNoteId) return;
try {
await server.post("special-notes/save-llm-chat", { llmChatNoteId: chatNoteId });
// Create a new empty chat after saving
const note = await dateNoteService.createLlmChat();
if (note) {
setChatNoteId(note.noteId);
chat.clearMessages();
}
} catch (err) {
console.error("Failed to save chat to permanent location:", err);
}
}, [chatNoteId, chat]);
const loadRecentChats = useCallback(async () => {
try {
const chats = await dateNoteService.getRecentLlmChats(10);
setRecentChats(chats);
} catch (err) {
console.error("Failed to load recent chats:", err);
}
}, []);
const handleSelectChat = useCallback(async (noteId: string) => {
historyDropdownRef.current?.hide();
if (noteId === chatNoteId) return;
try {
const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`);
if (blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
setChatNoteId(noteId);
chat.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load selected chat:", err);
}
}, [chatNoteId, chat]);
return (
<RightPanelWidget
id="sidebar-chat"
title={chatTitle}
grow
buttons={
<>
<ActionButton
icon="bx bx-plus"
text={t("sidebar_chat.new_chat")}
onClick={handleNewChat}
/>
<Dropdown
text=""
buttonClassName="bx bx-history"
title={t("sidebar_chat.history")}
iconAction
hideToggleArrow
dropdownContainerClassName="tn-dropdown-menu-scrollable"
dropdownOptions={{ popperConfig: { strategy: "fixed" } }}
dropdownRef={historyDropdownRef}
onShown={loadRecentChats}
>
{recentChats.length === 0 ? (
<FormListItem disabled>
{t("sidebar_chat.no_chats")}
</FormListItem>
) : (
recentChats.map(chatItem => (
<FormListItem
key={chatItem.noteId}
icon="bx bx-message-square-dots"
className={chatItem.noteId === chatNoteId ? "active" : ""}
onClick={() => handleSelectChat(chatItem.noteId)}
>
<div className="sidebar-chat-history-item-content">
{chatItem.noteId === chatNoteId
? <strong>{chatItem.title}</strong>
: <span>{chatItem.title}</span>}
<span className="sidebar-chat-history-date">
{formatDateTime(new Date(chatItem.dateModified), "short", "short")}
</span>
</div>
</FormListItem>
))
)}
</Dropdown>
<ActionButton
icon="bx bx-save"
text={t("sidebar_chat.save_chat")}
onClick={handleSaveChat}
disabled={chat.messages.length === 0}
/>
</>
}
>
<div className="sidebar-chat-container">
<div className="sidebar-chat-messages">
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
text={t("sidebar_chat.empty_state")}
/>
)}
{chat.messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{chat.toolActivity && !chat.streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{chat.toolActivity}
</div>
)}
{chat.isStreaming && chat.streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: chat.streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{chat.isStreaming && chat.streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: chat.streamingContent,
createdAt: new Date().toISOString(),
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}
isStreaming
/>
)}
<div ref={chat.messagesEndRef} />
</div>
<ChatInputBar
chat={chat}
rows={2}
activeNoteId={activeNoteId ?? undefined}
activeNoteTitle={activeNote?.title}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
/>
</div>
</RightPanelWidget>
);
}

View File

@@ -14,12 +14,11 @@ import SyncOptions from "./options/sync";
import OtherSettings from "./options/other";
import InternationalizationOptions from "./options/i18n";
import AdvancedSettings from "./options/advanced";
import LlmSettings from "./options/llm";
import "./ContentWidget.css";
import { t } from "../../services/i18n";
import BackendLog from "./code/BackendLog";
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced" | "_optionsLlm";
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
_optionsAppearance: AppearanceSettings,
@@ -36,7 +35,6 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetPro
_optionsOther: OtherSettings,
_optionsLocalization: InternationalizationOptions,
_optionsAdvanced: AdvancedSettings,
_optionsLlm: LlmSettings,
_backendLog: BackendLog
}

View File

@@ -1,238 +0,0 @@
import type { RefObject } from "preact";
import { useState, useCallback } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import ActionButton from "../../react/ActionButton.js";
import Button from "../../react/Button.js";
import Dropdown from "../../react/Dropdown.js";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../../react/FormList.js";
import type { UseLlmChatReturn } from "./useLlmChat.js";
import AddProviderModal, { type LlmProviderConfig } from "../options/llm/AddProviderModal.js";
import options from "../../../services/options.js";
/** Format token count with thousands separators */
function formatTokenCount(tokens: number): string {
return tokens.toLocaleString();
}
interface ChatInputBarProps {
/** The chat hook result */
chat: UseLlmChatReturn;
/** Number of rows for the textarea (default: 3) */
rows?: number;
/** Current active note ID (for note context toggle) */
activeNoteId?: string;
/** Current active note title (for note context toggle) */
activeNoteTitle?: string;
/** Custom submit handler (overrides chat.handleSubmit) */
onSubmit?: (e: Event) => void;
/** Custom key down handler (overrides chat.handleKeyDown) */
onKeyDown?: (e: KeyboardEvent) => void;
/** Callback when web search toggle changes */
onWebSearchChange?: () => void;
/** Callback when note tools toggle changes */
onNoteToolsChange?: () => void;
/** Callback when extended thinking toggle changes */
onExtendedThinkingChange?: () => void;
/** Callback when model changes */
onModelChange?: (model: string) => void;
}
export default function ChatInputBar({
chat,
rows = 3,
activeNoteId,
activeNoteTitle,
onSubmit,
onKeyDown,
onWebSearchChange,
onNoteToolsChange,
onExtendedThinkingChange,
onModelChange
}: ChatInputBarProps) {
const [showAddProviderModal, setShowAddProviderModal] = useState(false);
const handleSubmit = onSubmit ?? chat.handleSubmit;
const handleKeyDown = onKeyDown ?? chat.handleKeyDown;
const handleWebSearchToggle = (newValue: boolean) => {
chat.setEnableWebSearch(newValue);
onWebSearchChange?.();
};
const handleNoteToolsToggle = (newValue: boolean) => {
chat.setEnableNoteTools(newValue);
onNoteToolsChange?.();
};
const handleExtendedThinkingToggle = (newValue: boolean) => {
chat.setEnableExtendedThinking(newValue);
onExtendedThinkingChange?.();
};
const handleModelSelect = (model: string) => {
chat.setSelectedModel(model);
onModelChange?.(model);
};
const handleNoteContextToggle = () => {
if (chat.contextNoteId) {
chat.setContextNoteId(undefined);
} else if (activeNoteId) {
chat.setContextNoteId(activeNoteId);
}
};
const handleAddProvider = useCallback(async (provider: LlmProviderConfig) => {
// Get current providers and add the new one
const currentProviders = options.getJson("llmProviders") || [];
const newProviders = [...currentProviders, provider];
await options.save("llmProviders", JSON.stringify(newProviders));
// Refresh models to pick up the new provider
chat.refreshModels();
}, [chat]);
const isNoteContextEnabled = !!chat.contextNoteId && !!activeNoteId;
const currentModel = chat.availableModels.find(m => m.id === chat.selectedModel);
const currentModels = chat.availableModels.filter(m => !m.isLegacy);
const legacyModels = chat.availableModels.filter(m => m.isLegacy);
const contextWindow = currentModel?.contextWindow || 200000;
const percentage = Math.min((chat.lastPromptTokens / contextWindow) * 100, 100);
const isWarning = percentage > 75;
const isCritical = percentage > 90;
const pieColor = isCritical ? "var(--danger-color, #d9534f)" : isWarning ? "var(--warning-color, #f0ad4e)" : "var(--main-selection-color, #007bff)";
// Show setup prompt if no provider is configured
if (!chat.isCheckingProvider && !chat.hasProvider) {
return (
<div className="llm-chat-no-provider">
<div className="llm-chat-no-provider-content">
<span className="bx bx-bot llm-chat-no-provider-icon" />
<p>{t("llm_chat.no_provider_message")}</p>
<Button
text={t("llm_chat.add_provider")}
icon="bx bx-plus"
onClick={() => setShowAddProviderModal(true)}
/>
</div>
<AddProviderModal
show={showAddProviderModal}
onHidden={() => setShowAddProviderModal(false)}
onSave={handleAddProvider}
/>
</div>
);
}
return (
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
<textarea
ref={chat.textareaRef as RefObject<HTMLTextAreaElement>}
className="llm-chat-input"
value={chat.input}
onInput={(e) => chat.setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={chat.isStreaming}
onKeyDown={handleKeyDown}
rows={rows}
/>
<div className="llm-chat-options">
<div className="llm-chat-model-selector">
<span className="bx bx-chip" />
<Dropdown
text={<>{currentModel?.name}</>}
disabled={chat.isStreaming}
buttonClassName="llm-chat-model-select"
>
{currentModels.map(model => (
<FormListItem
key={model.id}
onClick={() => handleModelSelect(model.id)}
checked={chat.selectedModel === model.id}
>
{model.name} <small>({model.costDescription})</small>
</FormListItem>
))}
{legacyModels.length > 0 && (
<>
<FormDropdownDivider />
<FormDropdownSubmenu
icon="bx bx-history"
title={t("llm_chat.legacy_models")}
>
{legacyModels.map(model => (
<FormListItem
key={model.id}
onClick={() => handleModelSelect(model.id)}
checked={chat.selectedModel === model.id}
>
{model.name} <small>({model.costDescription})</small>
</FormListItem>
))}
</FormDropdownSubmenu>
</>
)}
<FormDropdownDivider />
<FormListToggleableItem
icon="bx bx-globe"
title={t("llm_chat.web_search")}
currentValue={chat.enableWebSearch}
onChange={handleWebSearchToggle}
disabled={chat.isStreaming}
/>
<FormListToggleableItem
icon="bx bx-note"
title={t("llm_chat.note_tools")}
currentValue={chat.enableNoteTools}
onChange={handleNoteToolsToggle}
disabled={chat.isStreaming}
/>
<FormListToggleableItem
icon="bx bx-brain"
title={t("llm_chat.extended_thinking")}
currentValue={chat.enableExtendedThinking}
onChange={handleExtendedThinkingToggle}
disabled={chat.isStreaming}
/>
</Dropdown>
{activeNoteId && activeNoteTitle && (
<Button
text={activeNoteTitle}
icon={isNoteContextEnabled ? "bx-file" : "bx-hide"}
kind="lowProfile"
size="micro"
className={`llm-chat-note-context ${isNoteContextEnabled ? "active" : ""}`}
onClick={handleNoteContextToggle}
disabled={chat.isStreaming}
title={isNoteContextEnabled
? t("llm_chat.note_context_enabled", { title: activeNoteTitle })
: t("llm_chat.note_context_disabled")}
/>
)}
{chat.lastPromptTokens > 0 && (
<div
className="llm-chat-context-indicator"
title={`${formatTokenCount(chat.lastPromptTokens)} / ${formatTokenCount(contextWindow)} ${t("llm_chat.tokens")}`}
>
<div
className="llm-chat-context-pie"
style={{
background: `conic-gradient(${pieColor} ${percentage}%, var(--accented-background-color) ${percentage}%)`
}}
/>
<span className="llm-chat-context-text">{t("llm_chat.context_used", { percentage: percentage.toFixed(0) })}</span>
</div>
)}
</div>
<ActionButton
icon={chat.isStreaming ? "bx bx-loader-alt bx-spin" : "bx bx-send"}
text={chat.isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
onClick={handleSubmit}
disabled={chat.isStreaming || !chat.input.trim()}
className="llm-chat-send-btn"
/>
</div>
</form>
);
}

View File

@@ -1,244 +0,0 @@
import "./LlmChat.css";
import { Marked } from "marked";
import { useMemo } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import { SanitizedHtml } from "../../react/RawHtml.js";
import { type ContentBlock, getMessageText, type StoredMessage, type ToolCall } from "./llm_chat_types.js";
function shortenNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`;
return n.toString();
}
// Configure marked for safe rendering
const markedInstance = new Marked({
breaks: true, // Convert \n to <br>
gfm: true // GitHub Flavored Markdown
});
/** Parse markdown to HTML. Sanitization is handled by SanitizedHtml. */
function renderMarkdown(markdown: string): string {
return markedInstance.parse(markdown) as string;
}
interface Props {
message: StoredMessage;
isStreaming?: boolean;
}
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
const classes = [
"llm-chat-tool-call-inline",
toolCall.isError && "llm-chat-tool-call-error"
].filter(Boolean).join(" ");
return (
<details className={classes}>
<summary className="llm-chat-tool-call-inline-summary">
<span className={toolCall.isError ? "bx bx-error-circle" : "bx bx-wrench"} />
{toolCall.toolName}
{toolCall.isError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
</summary>
<div className="llm-chat-tool-call-inline-body">
<div className="llm-chat-tool-call-input">
<strong>{t("llm_chat.input")}:</strong>
<pre>{JSON.stringify(toolCall.input, null, 2)}</pre>
</div>
{toolCall.result && (
<div className={`llm-chat-tool-call-result ${toolCall.isError ? "llm-chat-tool-call-result-error" : ""}`}>
<strong>{toolCall.isError ? t("llm_chat.error") : t("llm_chat.result")}:</strong>
<pre>{(() => {
if (typeof toolCall.result === "string" && (toolCall.result.startsWith("{") || toolCall.result.startsWith("["))) {
try {
return JSON.stringify(JSON.parse(toolCall.result), null, 2);
} catch {
return toolCall.result;
}
}
return toolCall.result;
})()}</pre>
</div>
)}
</div>
</details>
);
}
function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) {
return blocks.map((block, idx) => {
if (block.type === "text") {
const html = renderMarkdown(block.content);
return (
<div key={idx}>
<SanitizedHtml className="llm-chat-markdown" html={html} />
{isStreaming && idx === blocks.length - 1 && <span className="llm-chat-cursor" />}
</div>
);
}
if (block.type === "tool_call") {
return <ToolCallCard key={idx} toolCall={block.toolCall} />;
}
return null;
});
}
export default function ChatMessage({ message, isStreaming }: Props) {
const roleLabel = message.role === "user" ? t("llm_chat.role_user") : t("llm_chat.role_assistant");
const isError = message.type === "error";
const isThinking = message.type === "thinking";
const textContent = typeof message.content === "string" ? message.content : getMessageText(message.content);
// Render markdown for assistant messages with legacy string content
const renderedContent = useMemo(() => {
if (message.role === "assistant" && !isError && !isThinking && typeof message.content === "string") {
return renderMarkdown(message.content);
}
return null;
}, [message.content, message.role, isError, isThinking]);
const messageClasses = [
"llm-chat-message",
`llm-chat-message-${message.role}`,
isError && "llm-chat-message-error",
isThinking && "llm-chat-message-thinking"
].filter(Boolean).join(" ");
// Render thinking messages in a collapsible details element
if (isThinking) {
return (
<details className={messageClasses}>
<summary className="llm-chat-thinking-summary">
<span className="bx bx-brain" />
{t("llm_chat.thought_process")}
</summary>
<div className="llm-chat-message-content llm-chat-thinking-content">
{textContent}
{isStreaming && <span className="llm-chat-cursor" />}
</div>
</details>
);
}
// Legacy tool calls (from old format stored as separate field)
const legacyToolCalls = message.toolCalls;
const hasBlockContent = Array.isArray(message.content);
return (
<div className={`llm-chat-message-wrapper llm-chat-message-wrapper-${message.role}`}>
<div className={messageClasses}>
<div className="llm-chat-message-role">
{isError ? "Error" : roleLabel}
</div>
<div className="llm-chat-message-content">
{message.role === "assistant" && !isError ? (
hasBlockContent ? (
renderContentBlocks(message.content as ContentBlock[], isStreaming)
) : (
<>
<SanitizedHtml className="llm-chat-markdown" html={renderedContent || ""} />
{isStreaming && <span className="llm-chat-cursor" />}
</>
)
) : (
textContent
)}
</div>
{legacyToolCalls && legacyToolCalls.length > 0 && (
<details className="llm-chat-tool-calls">
<summary className="llm-chat-tool-calls-summary">
<span className="bx bx-wrench" />
{t("llm_chat.tool_calls", { count: legacyToolCalls.length })}
</summary>
<div className="llm-chat-tool-calls-list">
{legacyToolCalls.map((tool) => (
<ToolCallCard key={tool.id} toolCall={tool} />
))}
</div>
</details>
)}
{message.citations && message.citations.length > 0 && (
<div className="llm-chat-citations">
<div className="llm-chat-citations-label">
<span className="bx bx-link" />
{t("llm_chat.sources")}
</div>
<ul className="llm-chat-citations-list">
{message.citations.map((citation, idx) => {
// Determine display text: title, URL hostname, or cited text
let displayText = citation.title;
if (!displayText && citation.url) {
try {
displayText = new URL(citation.url).hostname;
} catch {
displayText = citation.url;
}
}
if (!displayText) {
displayText = citation.citedText?.slice(0, 50) || `Source ${idx + 1}`;
}
return (
<li key={idx}>
{citation.url ? (
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
title={citation.citedText || citation.url}
>
{displayText}
</a>
) : (
<span title={citation.citedText}>
{displayText}
</span>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
<div className={`llm-chat-footer llm-chat-footer-${message.role}`}>
<span
className="llm-chat-footer-time"
title={utils.formatDateTime(new Date(message.createdAt))}
>
{utils.formatTime(new Date(message.createdAt))}
</span>
{message.usage && typeof message.usage.promptTokens === "number" && (
<>
{message.usage.model && (
<>
<span className="llm-chat-usage-separator">·</span>
<span className="llm-chat-usage-model">{message.usage.model}</span>
</>
)}
<span className="llm-chat-usage-separator">·</span>
<span
className="llm-chat-usage-tokens"
title={t("llm_chat.tokens_detail", {
prompt: message.usage.promptTokens.toLocaleString(),
completion: message.usage.completionTokens.toLocaleString()
})}
>
<span className="bx bx-chip" />{" "}
{t("llm_chat.total_tokens", { total: shortenNumber(message.usage.totalTokens) })}
</span>
{message.usage.cost != null && (
<>
<span className="llm-chat-usage-separator">·</span>
<span className="llm-chat-usage-cost">~${message.usage.cost.toFixed(4)}</span>
</>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,725 +0,0 @@
.llm-chat-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
box-sizing: border-box;
}
.llm-chat-messages {
flex: 1;
overflow-y: auto;
padding-bottom: 1rem;
}
.llm-chat-message-wrapper {
position: relative;
margin-top: 1rem;
padding-bottom: 1.25rem;
max-width: 85%;
}
.llm-chat-message-wrapper:first-child {
margin-top: 0;
}
.llm-chat-message-wrapper-user {
margin-left: auto;
}
.llm-chat-message-wrapper-assistant {
margin-right: auto;
}
/* Show footer only on hover */
.llm-chat-message-wrapper:hover .llm-chat-footer {
opacity: 1;
}
.llm-chat-message {
padding: 0.75rem 1rem;
border-radius: 8px;
user-select: text;
}
.llm-chat-message-user {
background: var(--accented-background-color);
}
.llm-chat-message-assistant {
background: var(--main-background-color);
border: 1px solid var(--main-border-color);
}
.llm-chat-message-role {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: var(--muted-text-color);
}
.llm-chat-message-content {
word-wrap: break-word;
line-height: 1.5;
}
/* Preserve whitespace only for user messages (plain text) */
.llm-chat-message-user .llm-chat-message-content {
white-space: pre-wrap;
}
.llm-chat-cursor {
display: inline-block;
width: 8px;
height: 1.1em;
background: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
animation: llm-chat-blink 1s infinite;
}
@keyframes llm-chat-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* Tool activity indicator */
.llm-chat-tool-activity {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
border-radius: 8px;
background: var(--accented-background-color);
color: var(--muted-text-color);
font-size: 0.9rem;
max-width: 85%;
}
.llm-chat-tool-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--muted-text-color);
border-top-color: transparent;
border-radius: 50%;
animation: llm-chat-spin 0.8s linear infinite;
}
@keyframes llm-chat-spin {
to { transform: rotate(360deg); }
}
/* Citations */
.llm-chat-citations {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-citations-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-citations-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.llm-chat-citations-list li {
font-size: 0.8rem;
}
.llm-chat-citations-list a {
color: var(--link-color, #007bff);
text-decoration: none;
padding: 0.125rem 0.5rem;
background: var(--accented-background-color);
border-radius: 4px;
display: inline-block;
}
.llm-chat-citations-list a:hover {
text-decoration: underline;
}
/* Error */
.llm-chat-error {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 8px;
background: var(--danger-background-color, #fee);
border: 1px solid var(--danger-border-color, #fcc);
color: var(--danger-text-color, #c00);
user-select: text;
}
/* Error message (persisted in conversation) */
.llm-chat-message-error {
background: var(--danger-background-color, #fee);
border: 1px solid var(--danger-border-color, #fcc);
color: var(--danger-text-color, #c00);
}
.llm-chat-message-error .llm-chat-message-role {
color: var(--danger-text-color, #c00);
}
/* Thinking message (collapsible) */
.llm-chat-message-thinking {
background: var(--accented-background-color);
border: 1px dashed var(--main-border-color);
cursor: pointer;
}
.llm-chat-thinking-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted-text-color);
padding: 0.25rem 0;
list-style: none;
}
.llm-chat-thinking-summary::-webkit-details-marker {
display: none;
}
.llm-chat-thinking-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-message-thinking[open] .llm-chat-thinking-summary::before {
transform: rotate(90deg);
}
.llm-chat-thinking-summary .bx {
font-size: 1rem;
}
.llm-chat-thinking-content {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--main-border-color);
font-size: 0.9rem;
color: var(--muted-text-color);
white-space: pre-wrap;
}
/* Input form */
.llm-chat-input-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-input {
flex: 1;
min-height: 60px;
max-height: 200px;
resize: vertical;
padding: 0.75rem;
border: 1px solid var(--main-border-color);
border-radius: 8px;
font-family: inherit;
font-size: inherit;
background: var(--main-background-color);
color: var(--main-text-color);
}
.llm-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));
}
.llm-chat-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Options row */
.llm-chat-options {
display: flex;
align-items: center;
gap: 0.75rem;
}
.llm-chat-send-btn {
margin-left: auto;
font-size: 1.25rem;
}
.llm-chat-send-btn.disabled {
opacity: 0.4;
}
/* Model selector */
.llm-chat-model-selector {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.85rem;
color: var(--muted-text-color);
}
.llm-chat-model-selector .bx {
font-size: 1rem;
}
.llm-chat-model-selector .dropdown {
display: flex;
small {
margin-left: 0.5em;
color: var(--muted-text-color);
}
/* Position legacy models submenu to open upward */
.dropdown-submenu .dropdown-menu {
bottom: 0;
top: auto;
}
}
.llm-chat-model-select.select-button {
padding: 0.25rem 0.5rem;
border: 1px solid var(--main-border-color);
border-radius: 4px;
background: var(--main-background-color);
color: var(--main-text-color);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
min-width: 140px;
text-align: left;
}
.llm-chat-model-select.select-button:focus {
outline: none;
border-color: var(--main-selection-color);
}
.llm-chat-model-select.select-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Note context toggle */
.llm-chat-note-context.tn-low-profile {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.5;
background: none;
border: none;
}
.llm-chat-note-context.tn-low-profile:hover:not(:disabled) {
opacity: 0.8;
background: none;
}
.llm-chat-note-context.tn-low-profile.active {
opacity: 1;
}
/* Markdown styles */
.llm-chat-markdown {
line-height: 1.6;
}
.llm-chat-markdown p {
margin: 0 0 0.75em 0;
}
.llm-chat-markdown p:last-child {
margin-bottom: 0;
}
.llm-chat-markdown h1,
.llm-chat-markdown h2,
.llm-chat-markdown h3,
.llm-chat-markdown h4,
.llm-chat-markdown h5,
.llm-chat-markdown h6 {
margin: 1em 0 0.5em 0;
font-weight: 600;
line-height: 1.3;
}
.llm-chat-markdown h1:first-child,
.llm-chat-markdown h2:first-child,
.llm-chat-markdown h3:first-child {
margin-top: 0;
}
.llm-chat-markdown h1 { font-size: 1.4em; }
.llm-chat-markdown h2 { font-size: 1.25em; }
.llm-chat-markdown h3 { font-size: 1.1em; }
.llm-chat-markdown ul,
.llm-chat-markdown ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.llm-chat-markdown li {
margin: 0.25em 0;
}
.llm-chat-markdown code {
background: var(--accented-background-color);
padding: 0.15em 0.4em;
border-radius: 4px;
font-family: var(--monospace-font-family, monospace);
font-size: 0.9em;
}
.llm-chat-markdown pre {
background: var(--accented-background-color);
padding: 0.75em 1em;
border-radius: 6px;
overflow-x: auto;
margin: 0.75em 0;
}
.llm-chat-markdown pre code {
background: none;
padding: 0;
font-size: 0.85em;
}
.llm-chat-markdown blockquote {
margin: 0.75em 0;
padding: 0.5em 1em;
border-left: 3px solid var(--main-border-color);
background: var(--accented-background-color);
}
.llm-chat-markdown blockquote p {
margin: 0;
}
.llm-chat-markdown a {
color: var(--link-color, #007bff);
text-decoration: none;
}
.llm-chat-markdown a:hover {
text-decoration: underline;
}
.llm-chat-markdown hr {
border: none;
border-top: 1px solid var(--main-border-color);
margin: 1em 0;
}
.llm-chat-markdown table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
}
.llm-chat-markdown th,
.llm-chat-markdown td {
border: 1px solid var(--main-border-color);
padding: 0.5em 0.75em;
text-align: left;
}
.llm-chat-markdown th {
background: var(--accented-background-color);
font-weight: 600;
}
.llm-chat-markdown strong {
font-weight: 600;
}
.llm-chat-markdown em {
font-style: italic;
}
/* Tool calls display */
.llm-chat-tool-calls {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-tool-calls-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted-text-color);
padding: 0.25rem 0;
cursor: pointer;
list-style: none;
}
.llm-chat-tool-calls-summary::-webkit-details-marker {
display: none;
}
.llm-chat-tool-calls-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-tool-calls[open] .llm-chat-tool-calls-summary::before {
transform: rotate(90deg);
}
.llm-chat-tool-calls-summary .bx {
font-size: 1rem;
}
.llm-chat-tool-calls-list {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.llm-chat-tool-call {
background: var(--accented-background-color);
border-radius: 6px;
padding: 0.75rem;
font-size: 0.85rem;
}
.llm-chat-tool-call-name {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--main-text-color);
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-tool-call-input,
.llm-chat-tool-call-result {
margin-top: 0.5rem;
}
.llm-chat-tool-call-input strong,
.llm-chat-tool-call-result strong {
display: block;
font-size: 0.75rem;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-tool-call pre {
margin: 0;
padding: 0.5rem;
background: var(--main-background-color);
border-radius: 4px;
overflow-x: auto;
font-size: 0.8rem;
font-family: var(--monospace-font-family, monospace);
max-height: 200px;
overflow-y: auto;
}
/* Inline tool call cards (timeline style) */
.llm-chat-tool-call-inline {
margin: 0.5rem 0;
background: var(--accented-background-color);
border-radius: 6px;
border-left: 3px solid var(--muted-text-color);
font-size: 0.85rem;
}
.llm-chat-tool-call-inline-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
font-weight: 500;
color: var(--muted-text-color);
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-tool-call-inline-summary::-webkit-details-marker {
display: none;
}
.llm-chat-tool-call-inline-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-tool-call-inline[open] .llm-chat-tool-call-inline-summary::before {
transform: rotate(90deg);
}
.llm-chat-tool-call-inline-summary .bx {
font-size: 1rem;
}
.llm-chat-tool-call-inline-body {
padding: 0 0.75rem 0.75rem;
}
.llm-chat-tool-call-inline-body pre {
margin: 0;
padding: 0.5rem;
background: var(--main-background-color);
border-radius: 4px;
overflow-x: auto;
font-size: 0.8rem;
font-family: var(--monospace-font-family, monospace);
max-height: 200px;
overflow-y: auto;
}
.llm-chat-tool-call-inline-body strong {
display: block;
font-size: 0.75rem;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-tool-call-inline-body .llm-chat-tool-call-result {
margin-top: 0.5rem;
}
/* Tool call error styling */
.llm-chat-tool-call-error {
border-left-color: var(--danger-color, #dc3545);
}
.llm-chat-tool-call-error .llm-chat-tool-call-inline-summary {
color: var(--danger-color, #dc3545);
}
.llm-chat-tool-call-error-badge {
font-size: 0.75rem;
font-weight: 400;
font-family: var(--main-font-family);
color: var(--danger-color, #dc3545);
opacity: 0.8;
}
.llm-chat-tool-call-result-error pre {
color: var(--danger-color, #dc3545);
}
/* Message footer (timestamp + token usage, sits below the bubble) */
.llm-chat-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.7rem;
color: var(--muted-text-color);
cursor: default;
opacity: 0;
transition: opacity 0.15s ease;
}
.llm-chat-footer-user {
justify-content: flex-end;
}
.llm-chat-footer .bx {
font-size: 0.875rem;
}
.llm-chat-footer-time {
cursor: help;
}
.llm-chat-usage-model {
font-weight: 500;
}
.llm-chat-usage-separator {
opacity: 0.5;
}
.llm-chat-usage-tokens {
cursor: help;
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-usage-cost {
font-family: var(--monospace-font-family, monospace);
}
/* Context window indicator */
.llm-chat-context-indicator {
display: flex;
align-items: center;
gap: 0.375rem;
margin-left: 0.5rem;
cursor: help;
}
.llm-chat-context-pie {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.llm-chat-context-text {
font-size: 0.75rem;
color: var(--muted-text-color);
}
/* No provider state */
.llm-chat-no-provider {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-no-provider-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
color: var(--muted-text-color);
}
.llm-chat-no-provider-icon {
font-size: 2rem;
opacity: 0.5;
}
.llm-chat-no-provider-content p {
margin: 0;
font-size: 0.9rem;
}

View File

@@ -1,109 +0,0 @@
import "./LlmChat.css";
import { useCallback, useEffect, useRef } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import { useEditorSpacedUpdate } from "../../react/hooks.js";
import NoItems from "../../react/NoItems.js";
import { TypeWidgetProps } from "../type_widget.js";
import ChatInputBar from "./ChatInputBar.js";
import ChatMessage from "./ChatMessage.js";
import type { LlmChatContent } from "./llm_chat_types.js";
import { useLlmChat } from "./useLlmChat.js";
export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null);
const chat = useLlmChat(
// onMessagesChange - trigger save
() => spacedUpdateRef.current?.scheduleUpdate(),
{ defaultEnableNoteTools: false, supportsExtendedThinking: true, chatNoteId: note?.noteId }
);
// Keep chatNoteId in sync when the note changes
useEffect(() => {
chat.setChatNoteId(note?.noteId);
}, [note?.noteId, chat.setChatNoteId]);
const spacedUpdate = useEditorSpacedUpdate({
note,
noteType: "llmChat",
noteContext,
getData: () => {
const content = chat.getContent();
return { content: JSON.stringify(content) };
},
onContentChange: (content) => {
if (!content) {
chat.clearMessages();
return;
}
try {
const parsed: LlmChatContent = JSON.parse(content);
chat.loadFromContent(parsed);
} catch (e) {
console.error("Failed to parse LLM chat content:", e);
chat.clearMessages();
}
}
});
spacedUpdateRef.current = spacedUpdate;
const triggerSave = useCallback(() => {
spacedUpdateRef.current?.scheduleUpdate();
}, []);
return (
<div className="llm-chat-container">
<div className="llm-chat-messages">
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
text={t("llm_chat.empty_state")}
/>
)}
{chat.messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{chat.toolActivity && !chat.streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{chat.toolActivity}
</div>
)}
{chat.isStreaming && chat.streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: chat.streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{chat.isStreaming && chat.streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: chat.streamingContent,
createdAt: new Date().toISOString(),
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}
isStreaming
/>
)}
<div ref={chat.messagesEndRef} />
</div>
<ChatInputBar
chat={chat}
onWebSearchChange={triggerSave}
onNoteToolsChange={triggerSave}
onExtendedThinkingChange={triggerSave}
onModelChange={triggerSave}
/>
</div>
);
}

View File

@@ -1,80 +0,0 @@
import type { LlmCitation, LlmUsage } from "@triliumnext/commons";
export type MessageType = "message" | "error" | "thinking";
export interface ToolCall {
id: string;
toolName: string;
input: Record<string, unknown>;
result?: string;
isError?: boolean;
}
/** A block of text content (rendered as Markdown for assistant messages). */
export interface TextBlock {
type: "text";
content: string;
}
/** A tool invocation block shown inline in the message timeline. */
export interface ToolCallBlock {
type: "tool_call";
toolCall: ToolCall;
}
/** An ordered content block in an assistant message. */
export type ContentBlock = TextBlock | ToolCallBlock;
/**
* Extract the plain text from message content (works for both legacy string and block formats).
*/
export function getMessageText(content: string | ContentBlock[]): string {
if (typeof content === "string") {
return content;
}
return content
.filter((b): b is TextBlock => b.type === "text")
.map(b => b.content)
.join("");
}
/**
* Extract tool calls from message content blocks.
*/
export function getMessageToolCalls(message: StoredMessage): ToolCall[] {
// Legacy format: tool calls stored in separate field
if (message.toolCalls) {
return message.toolCalls;
}
// Block format: extract from content blocks
if (Array.isArray(message.content)) {
return message.content
.filter((b): b is ToolCallBlock => b.type === "tool_call")
.map(b => b.toolCall);
}
return [];
}
export interface StoredMessage {
id: string;
role: "user" | "assistant" | "system";
/** Message content: plain string (user messages, legacy) or ordered content blocks (assistant). */
content: string | ContentBlock[];
createdAt: string;
citations?: LlmCitation[];
/** Message type for special rendering. Defaults to "message" if omitted. */
type?: MessageType;
/** @deprecated Tool calls are now inline in content blocks. Kept for backward compatibility. */
toolCalls?: ToolCall[];
/** Token usage for this response */
usage?: LlmUsage;
}
export interface LlmChatContent {
version: 1;
messages: StoredMessage[];
selectedModel?: string;
enableWebSearch?: boolean;
enableNoteTools?: boolean;
enableExtendedThinking?: boolean;
}

View File

@@ -1,404 +0,0 @@
import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons";
import { RefObject } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import { getAvailableModels, streamChatCompletion } from "../../../services/llm_chat.js";
import { randomString } from "../../../services/utils.js";
import type { ContentBlock, LlmChatContent, StoredMessage } from "./llm_chat_types.js";
export interface ModelOption extends LlmModelInfo {
costDescription?: string;
}
export interface LlmChatOptions {
/** Default value for enableNoteTools */
defaultEnableNoteTools?: boolean;
/** Whether extended thinking is supported */
supportsExtendedThinking?: boolean;
/** Initial context note ID (the note the user is viewing) */
contextNoteId?: string;
/** The chat note ID (used for auto-renaming on first message) */
chatNoteId?: string;
}
export interface UseLlmChatReturn {
// State
messages: StoredMessage[];
input: string;
isStreaming: boolean;
streamingContent: string;
streamingThinking: string;
toolActivity: string | null;
pendingCitations: LlmCitation[];
availableModels: ModelOption[];
selectedModel: string;
enableWebSearch: boolean;
enableNoteTools: boolean;
enableExtendedThinking: boolean;
contextNoteId: string | undefined;
lastPromptTokens: number;
messagesEndRef: RefObject<HTMLDivElement>;
textareaRef: RefObject<HTMLTextAreaElement>;
/** Whether a provider is configured and available */
hasProvider: boolean;
/** Whether we're still checking for providers */
isCheckingProvider: boolean;
// Setters
setInput: (value: string) => void;
setMessages: (messages: StoredMessage[]) => void;
setSelectedModel: (model: string) => void;
setEnableWebSearch: (value: boolean) => void;
setEnableNoteTools: (value: boolean) => void;
setEnableExtendedThinking: (value: boolean) => void;
setContextNoteId: (noteId: string | undefined) => void;
setChatNoteId: (noteId: string | undefined) => void;
// Actions
handleSubmit: (e: Event) => Promise<void>;
handleKeyDown: (e: KeyboardEvent) => void;
loadFromContent: (content: LlmChatContent) => void;
getContent: () => LlmChatContent;
clearMessages: () => void;
/** Refresh the provider/models list */
refreshModels: () => void;
}
export function useLlmChat(
onMessagesChange?: (messages: StoredMessage[]) => void,
options: LlmChatOptions = {}
): UseLlmChatReturn {
const { defaultEnableNoteTools = false, supportsExtendedThinking = false, contextNoteId: initialContextNoteId, chatNoteId: initialChatNoteId } = options;
const [messages, setMessagesInternal] = useState<StoredMessage[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [toolActivity, setToolActivity] = useState<string | null>(null);
const [pendingCitations, setPendingCitations] = useState<LlmCitation[]>([]);
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [enableWebSearch, setEnableWebSearch] = useState(true);
const [enableNoteTools, setEnableNoteTools] = useState(defaultEnableNoteTools);
const [enableExtendedThinking, setEnableExtendedThinking] = useState(false);
const [contextNoteId, setContextNoteId] = useState<string | undefined>(initialContextNoteId);
const [chatNoteId, setChatNoteIdState] = useState<string | undefined>(initialChatNoteId);
const [lastPromptTokens, setLastPromptTokens] = useState<number>(0);
const [hasProvider, setHasProvider] = useState<boolean>(true); // Assume true initially
const [isCheckingProvider, setIsCheckingProvider] = useState<boolean>(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Refs to get fresh values in getContent (avoids stale closures)
const messagesRef = useRef(messages);
messagesRef.current = messages;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const enableWebSearchRef = useRef(enableWebSearch);
enableWebSearchRef.current = enableWebSearch;
const enableNoteToolsRef = useRef(enableNoteTools);
enableNoteToolsRef.current = enableNoteTools;
const enableExtendedThinkingRef = useRef(enableExtendedThinking);
enableExtendedThinkingRef.current = enableExtendedThinking;
const chatNoteIdRef = useRef(chatNoteId);
chatNoteIdRef.current = chatNoteId;
const setChatNoteId = useCallback((noteId: string | undefined) => {
chatNoteIdRef.current = noteId;
setChatNoteIdState(noteId);
}, []);
const contextNoteIdRef = useRef(contextNoteId);
contextNoteIdRef.current = contextNoteId;
// Wrapper to call onMessagesChange when messages update
const setMessages = useCallback((newMessages: StoredMessage[]) => {
setMessagesInternal(newMessages);
onMessagesChange?.(newMessages);
}, [onMessagesChange]);
// Fetch available models on mount
const refreshModels = useCallback(() => {
setIsCheckingProvider(true);
getAvailableModels().then(models => {
const modelsWithDescription = models.map(m => ({
...m,
costDescription: m.costMultiplier ? `${m.costMultiplier}x` : undefined
}));
setAvailableModels(modelsWithDescription);
setHasProvider(models.length > 0);
setIsCheckingProvider(false);
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);
setHasProvider(false);
setIsCheckingProvider(false);
});
}, [selectedModel]);
useEffect(() => {
refreshModels();
}, []);
// Scroll to bottom when content changes
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]);
// Load state from content object
const loadFromContent = useCallback((content: LlmChatContent) => {
setMessagesInternal(content.messages || []);
if (content.selectedModel) {
setSelectedModel(content.selectedModel);
}
if (typeof content.enableWebSearch === "boolean") {
setEnableWebSearch(content.enableWebSearch);
}
if (typeof content.enableNoteTools === "boolean") {
setEnableNoteTools(content.enableNoteTools);
}
if (supportsExtendedThinking && typeof content.enableExtendedThinking === "boolean") {
setEnableExtendedThinking(content.enableExtendedThinking);
}
// Restore last prompt tokens from the most recent message with usage
const lastUsage = [...(content.messages || [])].reverse().find(m => m.usage)?.usage;
setLastPromptTokens(lastUsage?.promptTokens ?? 0);
}, [supportsExtendedThinking]);
// Get current state as content object (uses refs to avoid stale closures)
const getContent = useCallback((): LlmChatContent => {
const content: LlmChatContent = {
version: 1,
messages: messagesRef.current,
selectedModel: selectedModelRef.current || undefined,
enableWebSearch: enableWebSearchRef.current,
enableNoteTools: enableNoteToolsRef.current
};
if (supportsExtendedThinking) {
content.enableExtendedThinking = enableExtendedThinkingRef.current;
}
return content;
}, [supportsExtendedThinking]);
const clearMessages = useCallback(() => {
setMessages([]);
setLastPromptTokens(0);
}, [setMessages]);
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
setToolActivity(null);
setPendingCitations([]);
const userMessage: StoredMessage = {
id: randomString(),
role: "user",
content: input.trim(),
createdAt: new Date().toISOString()
};
const newMessages = [...messages, userMessage];
setMessagesInternal(newMessages);
setInput("");
setIsStreaming(true);
setStreamingContent("");
setStreamingThinking("");
let thinkingContent = "";
const contentBlocks: ContentBlock[] = [];
const citations: LlmCitation[] = [];
let usage: LlmUsage | undefined;
/** Get or create the last text block to append streaming text to. */
function lastTextBlock(): ContentBlock & { type: "text" } {
const last = contentBlocks[contentBlocks.length - 1];
if (last?.type === "text") {
return last;
}
const block: ContentBlock = { type: "text", content: "" };
contentBlocks.push(block);
return block as ContentBlock & { type: "text" };
}
const apiMessages: LlmMessage[] = newMessages.map(m => ({
role: m.role,
content: typeof m.content === "string" ? m.content : m.content
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map(b => b.content)
.join("")
}));
const streamOptions: Parameters<typeof streamChatCompletion>[1] = {
model: selectedModel || undefined,
enableWebSearch,
enableNoteTools,
contextNoteId,
chatNoteId: chatNoteIdRef.current
};
if (supportsExtendedThinking) {
streamOptions.enableExtendedThinking = enableExtendedThinking;
}
await streamChatCompletion(
apiMessages,
streamOptions,
{
onChunk: (text) => {
lastTextBlock().content += text;
setStreamingContent(contentBlocks
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map(b => b.content)
.join(""));
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);
contentBlocks.push({
type: "tool_call",
toolCall: {
id: randomString(),
toolName,
input: toolInput
}
});
},
onToolResult: (toolName, result, isError) => {
// Find the most recent tool_call block for this tool without a result
for (let i = contentBlocks.length - 1; i >= 0; i--) {
const block = contentBlocks[i];
if (block.type === "tool_call" && block.toolCall.toolName === toolName && !block.toolCall.result) {
block.toolCall.result = result;
block.toolCall.isError = isError;
break;
}
}
},
onCitation: (citation) => {
citations.push(citation);
setPendingCitations([...citations]);
},
onUsage: (u) => {
usage = u;
setLastPromptTokens(u.promptTokens);
},
onError: (errorMsg) => {
console.error("Chat error:", errorMsg);
const errorMessage: StoredMessage = {
id: randomString(),
role: "assistant",
content: errorMsg,
createdAt: new Date().toISOString(),
type: "error"
};
const finalMessages = [...newMessages, errorMessage];
setMessages(finalMessages);
setStreamingContent("");
setStreamingThinking("");
setIsStreaming(false);
setToolActivity(null);
},
onDone: () => {
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (contentBlocks.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: contentBlocks,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
const allMessages = [...newMessages, ...finalNewMessages];
setMessages(allMessages);
}
setStreamingContent("");
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
setToolActivity(null);
}
}
);
}, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking, contextNoteId, supportsExtendedThinking, setMessages]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [handleSubmit]);
return {
// State
messages,
input,
isStreaming,
streamingContent,
streamingThinking,
toolActivity,
pendingCitations,
availableModels,
selectedModel,
enableWebSearch,
enableNoteTools,
enableExtendedThinking,
contextNoteId,
lastPromptTokens,
messagesEndRef,
textareaRef,
hasProvider,
isCheckingProvider,
// Setters
setInput,
setMessages,
setSelectedModel,
setEnableWebSearch,
setEnableNoteTools,
setEnableExtendedThinking,
setContextNoteId,
setChatNoteId,
// Actions
handleSubmit,
handleKeyDown,
loadFromContent,
getContent,
clearMessages,
refreshModels
};
}

View File

@@ -1,104 +0,0 @@
import { useCallback, useMemo, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import Button from "../../react/Button";
import OptionsSection from "./components/OptionsSection";
import AddProviderModal, { type LlmProviderConfig, PROVIDER_TYPES } from "./llm/AddProviderModal";
import ActionButton from "../../react/ActionButton";
import dialog from "../../../services/dialog";
import { useTriliumOption } from "../../react/hooks";
export default function LlmSettings() {
const [providersJson, setProvidersJson] = useTriliumOption("llmProviders");
const providers = useMemo<LlmProviderConfig[]>(() => {
try {
return providersJson ? JSON.parse(providersJson) : [];
} catch {
return [];
}
}, [providersJson]);
const setProviders = useCallback((newProviders: LlmProviderConfig[]) => {
setProvidersJson(JSON.stringify(newProviders));
}, [setProvidersJson]);
const [showAddModal, setShowAddModal] = useState(false);
const handleAddProvider = useCallback((newProvider: LlmProviderConfig) => {
setProviders([...providers, newProvider]);
}, [providers, setProviders]);
const handleDeleteProvider = useCallback(async (providerId: string, providerName: string) => {
if (!(await dialog.confirm(t("llm.delete_provider_confirmation", { name: providerName })))) {
return;
}
setProviders(providers.filter(p => p.id !== providerId));
}, [providers, setProviders]);
return (
<OptionsSection title={t("llm.settings_title")}>
<p>{t("llm.settings_description")}</p>
<Button
size="small"
icon="bx bx-plus"
text={t("llm.add_provider")}
onClick={() => setShowAddModal(true)}
/>
<hr />
<h5>{t("llm.configured_providers")}</h5>
<ProviderList
providers={providers}
onDelete={handleDeleteProvider}
/>
<AddProviderModal
show={showAddModal}
onHidden={() => setShowAddModal(false)}
onSave={handleAddProvider}
/>
</OptionsSection>
);
}
interface ProviderListProps {
providers: LlmProviderConfig[];
onDelete: (providerId: string, providerName: string) => Promise<void>;
}
function ProviderList({ providers, onDelete }: ProviderListProps) {
if (!providers.length) {
return <div>{t("llm.no_providers_configured")}</div>;
}
return (
<div style={{ overflow: "auto" }}>
<table className="table table-stripped">
<thead>
<tr>
<th>{t("llm.provider_name")}</th>
<th>{t("llm.provider_type")}</th>
<th>{t("llm.actions")}</th>
</tr>
</thead>
<tbody>
{providers.map((provider) => {
const providerType = PROVIDER_TYPES.find(p => p.id === provider.provider);
return (
<tr key={provider.id}>
<td>{provider.name}</td>
<td>{providerType?.name || provider.provider}</td>
<td>
<ActionButton
icon="bx bx-trash"
text={t("llm.delete_provider")}
onClick={() => onDelete(provider.id, provider.name)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -1,106 +0,0 @@
import { createPortal } from "preact/compat";
import { useState, useRef } from "preact/hooks";
import Modal from "../../../react/Modal";
import FormGroup from "../../../react/FormGroup";
import FormSelect from "../../../react/FormSelect";
import FormTextBox from "../../../react/FormTextBox";
import { t } from "../../../../services/i18n";
export interface LlmProviderConfig {
id: string;
name: string;
provider: string;
apiKey: string;
}
export interface ProviderType {
id: string;
name: string;
}
export const PROVIDER_TYPES: ProviderType[] = [
{ id: "anthropic", name: "Anthropic" }
];
interface AddProviderModalProps {
show: boolean;
onHidden: () => void;
onSave: (provider: LlmProviderConfig) => void;
}
export default function AddProviderModal({ show, onHidden, onSave }: AddProviderModalProps) {
const [selectedProvider, setSelectedProvider] = useState(PROVIDER_TYPES[0].id);
const [apiKey, setApiKey] = useState("");
const formRef = useRef<HTMLFormElement>(null);
function handleSubmit() {
if (!apiKey.trim()) {
return;
}
const providerType = PROVIDER_TYPES.find(p => p.id === selectedProvider);
const newProvider: LlmProviderConfig = {
id: `${selectedProvider}_${Date.now()}`,
name: providerType?.name || selectedProvider,
provider: selectedProvider,
apiKey: apiKey.trim()
};
onSave(newProvider);
resetForm();
onHidden();
}
function resetForm() {
setSelectedProvider(PROVIDER_TYPES[0].id);
setApiKey("");
}
function handleCancel() {
resetForm();
onHidden();
}
return createPortal(
<Modal
show={show}
onHidden={handleCancel}
onSubmit={handleSubmit}
formRef={formRef}
title={t("llm.add_provider_title")}
className="add-provider-modal"
size="md"
footer={
<>
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
{t("llm.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={!apiKey.trim()}>
{t("llm.add_provider")}
</button>
</>
}
>
<FormGroup name="provider-type" label={t("llm.provider_type")}>
<FormSelect
values={PROVIDER_TYPES}
keyProperty="id"
titleProperty="name"
currentValue={selectedProvider}
onChange={setSelectedProvider}
/>
</FormGroup>
<FormGroup name="api-key" label={t("llm.api_key")}>
<FormTextBox
type="password"
currentValue={apiKey}
onChange={setApiKey}
placeholder={t("llm.api_key_placeholder")}
autoFocus
/>
</FormGroup>
</Modal>,
document.body
);
}

View File

@@ -5,7 +5,7 @@
"description": "Tool to compare content of Trilium databases. Useful for debugging sync problems.",
"dependencies": {
"colors": "1.4.0",
"diff": "8.0.4",
"diff": "8.0.3",
"sqlite": "5.1.1",
"sqlite3": "6.0.1"
},

View File

@@ -15,7 +15,6 @@
"start-no-dir": "cross-env TRILIUM_PORT=37743 tsx ../../scripts/electron-start.mts src/main.ts",
"build": "tsx scripts/build.ts",
"start-prod": "pnpm build && cross-env TRILIUM_DATA_DIR=data TRILIUM_PORT=37841 ELECTRON_IS_DEV=0 electron dist",
"start-prod-no-dir": "pnpm build && cross-env TRILIUM_PORT=37841 ELECTRON_IS_DEV=0 electron dist",
"electron-forge:make": "pnpm build && electron-forge make dist",
"electron-forge:make-flatpak": "pnpm build && DEBUG=* electron-forge make dist --targets=@electron-forge/maker-flatpak",
"electron-forge:package": "pnpm build && electron-forge package dist",
@@ -36,7 +35,7 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.0",
"electron": "41.0.3",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",

View File

@@ -6,7 +6,7 @@
"dependencies": {
"better-sqlite3": "12.8.0",
"mime-types": "3.0.2",
"sanitize-filename": "1.6.4",
"sanitize-filename": "1.6.3",
"tsx": "4.21.0",
"yargs": "18.0.0"
},

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.0",
"electron": "41.0.3",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-bullseye-slim AS builder
FROM node:24.14.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-bullseye-slim
FROM node:24.14.0-bullseye-slim
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-alpine AS builder
FROM node:24.14.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-alpine
FROM node:24.14.0-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-alpine AS builder
FROM node:24.14.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-alpine
FROM node:24.14.0-alpine
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-bullseye-slim AS builder
FROM node:24.14.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-bullseye-slim
FROM node:24.14.0-bullseye-slim
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -30,8 +30,6 @@
"proxy-nginx-subdir": "docker run --name trilium-nginx-subdir --rm --network=host -v ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:latest"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.0",
"ai": "^5.0.0",
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
"node-html-parser": "7.1.0",
@@ -72,7 +70,7 @@
"@types/xml2js": "0.4.14",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"axios": "1.14.0",
"axios": "1.13.6",
"bindings": "1.5.0",
"bootstrap": "5.3.8",
"chardet": "2.1.1",
@@ -85,13 +83,13 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "41.1.0",
"electron": "41.0.3",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.20.1",
"express-openid-connect": "2.19.4",
"express-rate-limit": "8.3.1",
"express-session": "1.19.0",
"file-uri-to-path": "2.0.0",
@@ -101,21 +99,21 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "8.0.0",
"https-proxy-agent": "8.0.0",
"i18next": "25.10.10",
"i18next": "25.8.18",
"i18next-fs-backend": "2.6.1",
"image-type": "6.1.0",
"image-type": "6.0.0",
"ini": "6.0.0",
"is-animated": "2.0.2",
"is-svg": "6.1.0",
"jimp": "1.6.0",
"lorem-ipsum": "2.0.8",
"marked": "17.0.5",
"marked": "17.0.4",
"mime-types": "3.0.2",
"multer": "2.1.1",
"normalize-strings": "1.1.1",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.4",
"sanitize-filename": "1.6.3",
"sanitize-html": "2.17.2",
"sax": "1.6.0",
"serve-favicon": "2.5.1",
@@ -128,8 +126,8 @@
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.3",
"ws": "8.20.0",
"vite": "8.0.0",
"ws": "8.19.0",
"xml2js": "0.6.2",
"yauzl": "3.2.1"
}

View File

@@ -55,16 +55,7 @@ export default async function buildApp() {
});
if (!utils.isElectron) {
app.use(compression({
// Skip compression for SSE endpoints to enable real-time streaming
filter: (req, res) => {
// Skip compression for LLM chat streaming endpoint
if (req.path === "/api/llm-chat/stream") {
return false;
}
return compression.filter(req, res);
}
}));
app.use(compression()); // HTTP compression
}
let resourcePolicy = config["Network"]["corsResourcePolicy"] as 'same-origin' | 'same-site' | 'cross-origin' | undefined;

View File

@@ -155,6 +155,8 @@
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
"password": "密码",
"password-placeholder": "密码",
"totp-token": "TOTP 验证码",
"totp-token-placeholder": "请输入 TOTP 验证码",
"back": "返回",
"finish-setup": "完成设置"
},

View File

@@ -252,6 +252,8 @@
"proxy-instruction": "If you leave proxy setting blank, system proxy will be used (applies to the desktop application only)",
"password": "Password",
"password-placeholder": "Password",
"totp-token": "TOTP Token",
"totp-token-placeholder": "Enter your TOTP code",
"back": "Back",
"finish-setup": "Finish setup"
},
@@ -297,8 +299,7 @@
},
"quarterNumber": "Quarter {quarterNumber}",
"special_notes": {
"search_prefix": "Search:",
"llm_chat_prefix": "Chat:"
"search_prefix": "Search:"
},
"test_sync": {
"not-configured": "Sync server host is not configured. Please configure sync first.",
@@ -309,7 +310,6 @@
"search-history-title": "Search History",
"note-map-title": "Note Map",
"sql-console-history-title": "SQL Console History",
"llm-chat-history-title": "AI Chat History",
"shared-notes-title": "Shared Notes",
"bulk-action-title": "Bulk Action",
"backend-log-title": "Backend Log",
@@ -353,13 +353,11 @@
"sync-title": "Sync",
"other": "Other",
"advanced-title": "Advanced",
"llm-title": "AI / LLM",
"visible-launchers-title": "Visible Launchers",
"user-guide": "User Guide",
"localization": "Language & Region",
"inbox-title": "Inbox",
"tab-switcher-title": "Tab Switcher",
"sidebar-chat-title": "AI Chat"
"tab-switcher-title": "Tab Switcher"
},
"notes": {
"new-note": "New note",

View File

@@ -14,7 +14,7 @@
"creating-and-moving-notes": "Tworzenie i przenoszenie notatek",
"create-note-after": "Utwórz notatkę po aktywnej notatce",
"create-note-into": "Utwórz notatkę jako podrzędną aktywnej notatki",
"create-note-into-inbox": "Utwórz notatkę w skrzynce odbiorczej (jeśli zdefiniowano) lub w notatce dziennej",
"create-note-into-inbox": "Utwórz notatkę w skrzynce odbiorczej (jeśli zdefiniowana) lub notatkę dnia",
"delete-note": "Usuń notatkę",
"move-note-up": "Przenieś notatkę w górę",
"move-note-down": "Przenieś notatkę w dół",
@@ -59,7 +59,7 @@
"show-backend-log": "Otwórz stronę \"Logi backendu\"",
"show-help": "Otwórz wbudowany Poradnik Użytkownika",
"show-cheatsheet": "Pokaż listę skrótów klawiszowych",
"text-note-operations": "Operacje na notatkach",
"text-note-operations": "Operacje na notatkach tekstowych",
"add-link-to-text": "Otwórz okno dodawania linku do tekstu",
"follow-link-under-cursor": "Podążaj za linkiem pod kursorem",
"insert-date-and-time-to-text": "Wstaw aktualną datę i czas",

View File

@@ -155,6 +155,8 @@
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
"password": "密碼",
"password-placeholder": "密碼",
"totp-token": "TOTP 驗證碼",
"totp-token-placeholder": "請輸入 TOTP 驗證碼",
"back": "返回",
"finish-setup": "完成設定"
},

View File

@@ -141,6 +141,10 @@
<label for="password"><%= t("setup_sync-from-server.password") %></label>
<input type="password" id="password" class="form-control" placeholder="<%= t("setup_sync-from-server.password-placeholder") %>">
</div>
<div id="totp-section" class="form-group" style="margin-bottom: 8px; display: none;">
<label for="totp-token"><%= t("setup_sync-from-server.totp-token") %></label>
<input type="text" id="totp-token" class="form-control" placeholder="<%= t("setup_sync-from-server.totp-token-placeholder") %>" autocomplete="one-time-code">
</div>
<button type="button" data-action="back" class="btn btn-secondary"><%= t("setup_sync-from-server.back") %></button>

View File

@@ -8,6 +8,7 @@ export declare module "express-serve-static-core" {
authorization?: string;
"trilium-cred"?: string;
"trilium-totp"?: string;
"x-csrf-token"?: string;
"trilium-component-id"?: string;
@@ -17,11 +18,6 @@ export declare module "express-serve-static-core" {
"user-agent"?: string;
};
}
interface Response {
/** Set to true to prevent apiResultHandler from double-handling the response (e.g., for SSE streams) */
triliumResponseHandled?: boolean;
}
}
export declare module "express-session" {

View File

@@ -1,109 +0,0 @@
import type { LlmMessage } from "@triliumnext/commons";
import type { Request, Response } from "express";
import { generateChatTitle } from "../../services/llm/chat_title.js";
import { getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js";
import { streamToChunks } from "../../services/llm/stream.js";
import log from "../../services/log.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
interface ChatRequest {
messages: LlmMessage[];
config?: LlmProviderConfig;
}
/**
* SSE endpoint for streaming chat completions.
*
* Response format (Server-Sent Events):
* data: {"type":"text","content":"Hello"}
* data: {"type":"text","content":" world"}
* data: {"type":"done"}
*
* On error:
* data: {"type":"error","error":"Error message"}
*/
async function streamChat(req: Request, res: Response) {
const { messages, config = {} } = req.body as ChatRequest;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: "messages array is required" });
return;
}
// Set up SSE headers - disable compression and buffering for real-time streaming
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
res.flushHeaders();
// Mark response as handled to prevent double-handling by apiResultHandler
res.triliumResponseHandled = true;
// Type assertion for flush method (available when compression is used)
const flushableRes = res as Response & { flush?: () => void };
try {
if (!hasConfiguredProviders()) {
res.write(`data: ${JSON.stringify({ type: "error", error: "No LLM providers configured. Please add a provider in Options → AI / LLM." })}\n\n`);
return;
}
const provider = getProviderByType(config.provider || "anthropic");
const result = provider.chat(messages, config);
// Get pricing and display name for the model
const modelId = config.model || provider.getAvailableModels().find(m => m.isDefault)?.id;
if (!modelId) {
res.write(`data: ${JSON.stringify({ type: "error", error: "No model specified and no default model available for the provider." })}\n\n`);
return;
}
const pricing = provider.getModelPricing(modelId);
const modelDisplayName = provider.getAvailableModels().find(m => m.id === modelId)?.name || modelId;
for await (const chunk of streamToChunks(result, { model: modelDisplayName, pricing })) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
// Flush immediately to ensure real-time streaming
if (typeof flushableRes.flush === "function") {
flushableRes.flush();
}
}
// Auto-generate a title for the chat note on the first user message
const userMessages = messages.filter(m => m.role === "user");
if (userMessages.length === 1 && config.chatNoteId) {
try {
await generateChatTitle(config.chatNoteId, userMessages[0].content);
} catch (err) {
// Title generation is best-effort; don't fail the chat
log.error(`Failed to generate chat title: ${safeExtractMessageAndStackFromError(err)}`);
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
res.write(`data: ${JSON.stringify({ type: "error", error: errorMessage })}\n\n`);
} finally {
res.end();
}
}
/**
* Get available models for a provider.
*/
function getModels(req: Request, _res: Response) {
const providerType = req.query.provider as string || "anthropic";
// Return empty array when no providers configured - client handles this gracefully
if (!hasConfiguredProviders()) {
return { models: [] };
}
const llmProvider = getProviderByType(providerType);
const models = llmProvider.getAvailableModels();
return { models };
}
export default {
streamChat,
getModels
};

View File

@@ -104,8 +104,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"experimentalFeatures",
"newLayout",
"mfaEnabled",
"mfaMethod",
"llmProviders"
"mfaMethod"
]);
function getOptions() {

View File

@@ -1,16 +1,19 @@
"use strict";
import sqlInit from "../../services/sql_init.js";
import setupService from "../../services/setup.js";
import log from "../../services/log.js";
import appInfo from "../../services/app_info.js";
import type { Request } from "express";
import appInfo from "../../services/app_info.js";
import log from "../../services/log.js";
import setupService from "../../services/setup.js";
import sqlInit from "../../services/sql_init.js";
import totp from "../../services/totp.js";
function getStatus() {
return {
isInitialized: sqlInit.isDbInitialized(),
schemaExists: sqlInit.schemaExists(),
syncVersion: appInfo.syncVersion
syncVersion: appInfo.syncVersion,
totpEnabled: totp.isTotpEnabled()
};
}
@@ -19,9 +22,9 @@ async function setupNewDocument() {
}
function setupSyncFromServer(req: Request) {
const { syncServerHost, syncProxy, password } = req.body;
const { syncServerHost, syncProxy, password, totpToken } = req.body;
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password);
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password, totpToken);
}
function saveSyncSeed(req: Request) {
@@ -82,10 +85,26 @@ function getSyncSeed() {
};
}
async function checkServerTotpStatus(req: Request) {
const { syncServerHost } = req.body;
if (!syncServerHost) {
return { totpEnabled: false };
}
try {
const resp = await setupService.checkRemoteTotpStatus(syncServerHost);
return { totpEnabled: !!resp.totpEnabled };
} catch {
return { totpEnabled: false };
}
}
export default {
getStatus,
setupNewDocument,
setupSyncFromServer,
getSyncSeed,
saveSyncSeed
saveSyncSeed,
checkServerTotpStatus
};

View File

@@ -86,29 +86,6 @@ function createSearchNote(req: Request) {
return specialNotesService.createSearchNote(searchString, ancestorNoteId);
}
function createLlmChat() {
return specialNotesService.createLlmChat();
}
function getMostRecentLlmChat() {
const chat = specialNotesService.getMostRecentLlmChat();
// Return null explicitly if no chat found (not undefined)
return chat || null;
}
function getOrCreateLlmChat() {
return specialNotesService.getOrCreateLlmChat();
}
function getRecentLlmChats(req: Request) {
const limit = parseInt(req.query.limit as string) || 10;
return specialNotesService.getRecentLlmChats(limit);
}
function saveLlmChat(req: Request) {
return specialNotesService.saveLlmChat(req.body.llmChatNoteId);
}
function getHoistedNote() {
return becca.getNote(cls.getHoistedNoteId());
}
@@ -142,11 +119,6 @@ export default {
saveSqlConsole,
createSearchNote,
saveSearchNote,
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats,
saveLlmChat,
createLauncher,
resetLauncher,
createOrUpdateScriptLauncherFromApi

View File

@@ -115,7 +115,6 @@ class FakeResponse extends EventEmitter implements Pick<Response<any, Record<str
}
json(obj) {
this.respHeaders["Content-Type"] = "application/json";
this.send(JSON.stringify(obj));
return this as unknown as MockedResponse;
}

View File

@@ -145,7 +145,7 @@ function internalRoute<P extends ParamsDictionary>(method: HttpMethod, path: str
function handleResponse(resultHandler: ApiResultHandler, req: express.Request, res: express.Response, result: unknown, start: number) {
// Skip result handling if the response has already been handled
if (res.triliumResponseHandled) {
if ((res as any).triliumResponseHandled) {
// Just log the request without additional processing
log.request(req, res, Date.now() - start, 0);
return;
@@ -161,7 +161,7 @@ function handleException(e: unknown | Error, method: HttpMethod, path: string, r
log.error(`${method} ${path} threw exception: '${errMessage}', stack: ${errStack}`);
// Skip sending response if it's already been handled by the route handler
if (res.triliumResponseHandled || res.headersSent) {
if ((res as unknown as { triliumResponseHandled?: boolean }).triliumResponseHandled || res.headersSent) {
return;
}

View File

@@ -34,7 +34,6 @@ import fontsRoute from "./api/fonts.js";
import imageRoute from "./api/image.js";
import importRoute from "./api/import.js";
import keysRoute from "./api/keys.js";
import llmChatRoute from "./api/llm_chat.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
import noteMapRoute from "./api/note_map.js";
@@ -245,6 +244,7 @@ function register(app: express.Application) {
asyncRoute(PST, "/api/setup/sync-from-server", [auth.checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler);
route(GET, "/api/setup/sync-seed", [loginRateLimiter, auth.checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler);
asyncRoute(PST, "/api/setup/sync-seed", [auth.checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler);
asyncRoute(PST, "/api/setup/check-server-totp", [auth.checkAppNotInitialized], setupApiRoute.checkServerTotpStatus, apiResultHandler);
apiRoute(GET, "/api/autocomplete", autocompleteApiRoute.getAutocomplete);
apiRoute(GET, "/api/autocomplete/notesCount", autocompleteApiRoute.getNotesCount);
@@ -292,11 +292,6 @@ function register(app: express.Application) {
asyncApiRoute(PST, "/api/special-notes/save-sql-console", specialNotesRoute.saveSqlConsole);
apiRoute(PST, "/api/special-notes/search-note", specialNotesRoute.createSearchNote);
apiRoute(PST, "/api/special-notes/save-search-note", specialNotesRoute.saveSearchNote);
apiRoute(PST, "/api/special-notes/llm-chat", specialNotesRoute.createLlmChat);
apiRoute(GET, "/api/special-notes/most-recent-llm-chat", specialNotesRoute.getMostRecentLlmChat);
apiRoute(GET, "/api/special-notes/get-or-create-llm-chat", specialNotesRoute.getOrCreateLlmChat);
apiRoute(GET, "/api/special-notes/recent-llm-chats", specialNotesRoute.getRecentLlmChats);
apiRoute(PST, "/api/special-notes/save-llm-chat", specialNotesRoute.saveLlmChat);
apiRoute(PST, "/api/special-notes/launchers/:noteId/reset", specialNotesRoute.resetLauncher);
apiRoute(PST, "/api/special-notes/launchers/:parentNoteId/:launcherType", specialNotesRoute.createLauncher);
apiRoute(PUT, "/api/special-notes/api-script-launcher", specialNotesRoute.createOrUpdateScriptLauncherFromApi);
@@ -329,10 +324,6 @@ function register(app: express.Application) {
apiRoute(PST, "/api/script/bundle/:noteId", scriptRoute.getBundle);
apiRoute(GET, "/api/script/relation/:noteId/:relationName", scriptRoute.getRelationBundles);
// LLM chat endpoints
asyncRoute(PST, "/api/llm-chat/stream", [auth.checkApiAuthOrElectron, csrfMiddleware], llmChatRoute.streamChat, null);
apiRoute(GET, "/api/llm-chat/models", llmChatRoute.getModels);
// no CSRF since this is called from android app
route(PST, "/api/sender/login", [loginRateLimiter], loginApiRoute.token, apiResultHandler);
asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler);

View File

@@ -6,6 +6,7 @@ import type { OptionRow } from "@triliumnext/commons";
export interface SetupStatusResponse {
syncVersion: number;
schemaExists: boolean;
totpEnabled: boolean;
}
/**

View File

@@ -9,6 +9,10 @@ import options from "./options";
let app: Application;
function encodeCred(password: string): string {
return Buffer.from(`dummy:${password}`).toString("base64");
}
describe("Auth", () => {
beforeAll(async () => {
const buildApp = (await (import("../../src/app.js"))).default;
@@ -72,4 +76,49 @@ describe("Auth", () => {
.expect(200);
});
});
describe("Setup status endpoint", () => {
it("returns totpEnabled: true when TOTP is enabled", async () => {
cls.init(() => {
options.setOption("mfaEnabled", "true");
options.setOption("mfaMethod", "totp");
options.setOption("totpVerificationHash", "hi");
});
const response = await supertest(app)
.get("/api/setup/status")
.expect(200);
expect(response.body.totpEnabled).toBe(true);
});
it("returns totpEnabled: false when TOTP is disabled", async () => {
cls.init(() => {
options.setOption("mfaEnabled", "false");
});
const response = await supertest(app)
.get("/api/setup/status")
.expect(200);
expect(response.body.totpEnabled).toBe(false);
});
});
describe("checkCredentials TOTP enforcement", () => {
beforeAll(() => {
config.General.noAuthentication = false;
refreshAuth();
});
it("does not require TOTP token when TOTP is disabled", async () => {
cls.init(() => {
options.setOption("mfaEnabled", "false");
});
// Will still fail with 401 due to wrong password, but NOT because of missing TOTP
const response = await supertest(app)
.get("/api/setup/sync-seed")
.set("trilium-cred", encodeCred("wrongpassword"))
.expect(401);
// The error should be about password, not TOTP
expect(response.text).toContain("Incorrect password");
});
});
}, 60_000);

View File

@@ -1,15 +1,17 @@
import etapiTokenService from "./etapi_tokens.js";
import log from "./log.js";
import sqlInit from "./sql_init.js";
import { isElectron } from "./utils.js";
import passwordEncryptionService from "./encryption/password_encryption.js";
import type { NextFunction, Request, Response } from "express";
import attributes from "./attributes.js";
import config from "./config.js";
import passwordService from "./encryption/password.js";
import totp from "./totp.js";
import passwordEncryptionService from "./encryption/password_encryption.js";
import recoveryCodeService from "./encryption/recovery_codes.js";
import etapiTokenService from "./etapi_tokens.js";
import log from "./log.js";
import openID from "./open_id.js";
import options from "./options.js";
import attributes from "./attributes.js";
import type { NextFunction, Request, Response } from "express";
import sqlInit from "./sql_init.js";
import totp from "./totp.js";
import { isElectron } from "./utils.js";
let noAuthentication = false;
refreshAuth();
@@ -161,9 +163,28 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) {
if (!passwordEncryptionService.verifyPassword(password)) {
res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect password");
log.info(`WARNING: Wrong password from ${req.ip}, rejecting.`);
} else {
next();
return;
}
// Verify TOTP if enabled
if (totp.isTotpEnabled()) {
const totpHeader = req.headers["trilium-totp"];
const totpToken = Array.isArray(totpHeader) ? totpHeader[0] : totpHeader;
if (typeof totpToken !== "string" || !totpToken) {
res.setHeader("Content-Type", "text/plain").status(401).send("TOTP token is required");
log.info(`WARNING: Missing or invalid TOTP token from ${req.ip}, rejecting.`);
return;
}
// Accept TOTP code or recovery code
if (!totp.validateTOTP(totpToken) && !recoveryCodeService.verifyRecoveryCode(totpToken)) {
res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect TOTP token");
log.info(`WARNING: Wrong TOTP token from ${req.ip}, rejecting.`);
return;
}
}
next();
}
export default {

View File

@@ -1,9 +1,10 @@
import optionService from "../options.js";
import myScryptService from "./my_scrypt.js";
import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js";
import dataEncryptionService from "./data_encryption.js";
import type { OptionNames } from "@triliumnext/commons";
import optionService from "../options.js";
import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js";
import dataEncryptionService from "./data_encryption.js";
import myScryptService from "./my_scrypt.js";
const TOTP_OPTIONS: Record<string, OptionNames> = {
SALT: "totpEncryptionSalt",
ENCRYPTED_SECRET: "totpEncryptedSecret",

View File

@@ -66,12 +66,6 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
type: "doc",
icon: "bx-data"
},
{
id: "_llmChat",
title: t("hidden-subtree.llm-chat-history-title"),
type: "doc",
icon: "bx-message-square-dots"
},
{
id: "_share",
title: t("hidden-subtree.shared-notes-title"),
@@ -253,7 +247,6 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
{ id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" },
{ id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" },
{ id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" },
{ id: "_optionsLlm", title: t("hidden-subtree.llm-title"), type: "contentWidget", icon: "bx-bot" },
{ id: "_optionsAi", title: "AI Chat", type: "contentWidget", enforceDeleted: true },
{ id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" },
{ id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" },

View File

@@ -78,13 +78,6 @@ 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"
}
];

View File

@@ -1,37 +0,0 @@
import becca from "../../becca/becca.js";
import { getProvider } from "./index.js";
import log from "../log.js";
import { t } from "i18next";
/** Default title prefixes that indicate the note hasn't been manually renamed. */
function hasDefaultTitle(title: string): boolean {
// "Chat: <timestamp>" from sidebar/API-created chats
const chatPrefix = t("special_notes.llm_chat_prefix");
// "New note" from manually created chats
const newNoteTitle = t("notes.new-note");
return title.startsWith(chatPrefix) || title === newNoteTitle;
}
/**
* Generate a short descriptive title for a chat note based on the first user message,
* then rename the note. Only renames if the note still has a default title.
*/
export async function generateChatTitle(chatNoteId: string, firstMessage: string): Promise<void> {
const note = becca.getNote(chatNoteId);
if (!note) {
return;
}
if (!hasDefaultTitle(note.title)) {
return;
}
const provider = getProvider();
const title = await provider.generateTitle(firstMessage);
if (title) {
note.title = title;
note.save();
log.info(`Auto-renamed chat note ${chatNoteId} to "${title}"`);
}
}

View File

@@ -1,105 +0,0 @@
import type { LlmProvider } from "./types.js";
import { AnthropicProvider } from "./providers/anthropic.js";
import optionService from "../options.js";
import log from "../log.js";
/**
* Configuration for a single LLM provider instance.
* This matches the structure stored in the llmProviders option.
*/
export interface LlmProviderSetup {
id: string;
name: string;
provider: string;
apiKey: string;
}
/** Factory functions for creating provider instances */
const providerFactories: Record<string, (apiKey: string) => LlmProvider> = {
anthropic: (apiKey) => new AnthropicProvider(apiKey)
};
/** Cache of instantiated providers by their config ID */
let cachedProviders: Record<string, LlmProvider> = {};
/**
* Get configured providers from the options.
*/
function getConfiguredProviders(): LlmProviderSetup[] {
try {
const providersJson = optionService.getOptionOrNull("llmProviders");
if (!providersJson) {
return [];
}
return JSON.parse(providersJson) as LlmProviderSetup[];
} catch (e) {
log.error(`Failed to parse llmProviders option: ${e}`);
return [];
}
}
/**
* Get a provider instance by its configuration ID.
* If no ID is provided, returns the first configured provider.
*/
export function getProvider(providerId?: string): LlmProvider {
const configs = getConfiguredProviders();
if (configs.length === 0) {
throw new Error("No LLM providers configured. Please add a provider in Options → AI / LLM.");
}
// Find the requested provider or use the first one
const config = providerId
? configs.find(c => c.id === providerId)
: configs[0];
if (!config) {
throw new Error(`LLM provider not found: ${providerId}`);
}
// Check cache
if (cachedProviders[config.id]) {
return cachedProviders[config.id];
}
// Create new provider instance
const factory = providerFactories[config.provider];
if (!factory) {
throw new Error(`Unknown LLM provider type: ${config.provider}. Available: ${Object.keys(providerFactories).join(", ")}`);
}
const provider = factory(config.apiKey);
cachedProviders[config.id] = provider;
return provider;
}
/**
* Get the first configured provider of a specific type (e.g., "anthropic").
*/
export function getProviderByType(providerType: string): LlmProvider {
const configs = getConfiguredProviders();
const config = configs.find(c => c.provider === providerType);
if (!config) {
throw new Error(`No ${providerType} provider configured. Please add one in Options → AI / LLM.`);
}
return getProvider(config.id);
}
/**
* Check if any providers are configured.
*/
export function hasConfiguredProviders(): boolean {
return getConfiguredProviders().length > 0;
}
/**
* Clear the provider cache. Call this when provider configurations change.
*/
export function clearProviderCache(): void {
cachedProviders = {};
}
export type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing } from "./types.js";

View File

@@ -1,244 +0,0 @@
import { createAnthropic, type AnthropicProvider as AnthropicSDKProvider } from "@ai-sdk/anthropic";
import { generateText, streamText, stepCountIs, type CoreMessage, type ToolSet } from "ai";
import type { LlmMessage } from "@triliumnext/commons";
import becca from "../../../becca/becca.js";
import { noteTools, attributeTools, currentNoteTools } from "../tools/index.js";
import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js";
const DEFAULT_MODEL = "claude-sonnet-4-6";
const DEFAULT_MAX_TOKENS = 8096;
const TITLE_MODEL = "claude-haiku-4-5-20251001";
const TITLE_MAX_TOKENS = 30;
/**
* Calculate effective cost for comparison (weighted average: 1 input + 3 output).
* Output is weighted more heavily as it's typically the dominant cost factor.
*/
function effectiveCost(pricing: ModelPricing): number {
return (pricing.input + 3 * pricing.output) / 4;
}
/**
* Available Anthropic models with pricing (USD per million tokens).
* Source: https://docs.anthropic.com/en/docs/about-claude/models
*/
const BASE_MODELS: Omit<ModelInfo, "costMultiplier">[] = [
// ===== Current Models =====
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
pricing: { input: 3, output: 15 },
contextWindow: 1000000,
isDefault: true
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
pricing: { input: 5, output: 25 },
contextWindow: 1000000
},
{
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
pricing: { input: 1, output: 5 },
contextWindow: 200000
},
// ===== Legacy Models =====
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
pricing: { input: 3, output: 15 },
contextWindow: 200000, // 1M available with beta header
isLegacy: true
},
{
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
pricing: { input: 5, output: 25 },
contextWindow: 200000,
isLegacy: true
},
{
id: "claude-opus-4-1-20250805",
name: "Claude Opus 4.1",
pricing: { input: 15, output: 75 },
contextWindow: 200000,
isLegacy: true
},
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4.0",
pricing: { input: 3, output: 15 },
contextWindow: 200000, // 1M available with beta header
isLegacy: true
},
{
id: "claude-opus-4-20250514",
name: "Claude Opus 4.0",
pricing: { input: 15, output: 75 },
contextWindow: 200000,
isLegacy: true
}
];
// Use default model (Sonnet) as baseline for cost multiplier
const baselineModel = BASE_MODELS.find(m => m.isDefault) || BASE_MODELS[0];
const baselineCost = effectiveCost(baselineModel.pricing);
// Build models with cost multipliers
const AVAILABLE_MODELS: ModelInfo[] = BASE_MODELS.map(m => ({
...m,
costMultiplier: Math.round((effectiveCost(m.pricing) / baselineCost) * 10) / 10
}));
// Build pricing lookup from available models
const MODEL_PRICING: Record<string, ModelPricing> = Object.fromEntries(
AVAILABLE_MODELS.map(m => [m.id, m.pricing])
);
/**
* Build a lightweight context hint about the current note (title + type only, no content).
* The full content is available via the get_current_note tool.
*/
function buildNoteHint(noteId: string): string | null {
const note = becca.getNote(noteId);
if (!note) {
return null;
}
return `The user is currently viewing a ${note.type} note titled "${note.title}". Use the get_current_note tool to read its content if needed.`;
}
export class AnthropicProvider implements LlmProvider {
name = "anthropic";
private anthropic: AnthropicSDKProvider;
constructor(apiKey: string) {
if (!apiKey) {
throw new Error("API key is required for Anthropic provider");
}
this.anthropic = createAnthropic({ apiKey });
}
chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult {
let systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
const chatMessages = messages.filter(m => m.role !== "system");
// Add a lightweight hint about the current note (content available via tool)
if (config.contextNoteId) {
const noteHint = buildNoteHint(config.contextNoteId);
if (noteHint) {
systemPrompt = systemPrompt
? `${systemPrompt}\n\n${noteHint}`
: noteHint;
}
}
// Convert to AI SDK message format with cache control breakpoints.
// The system prompt and conversation history (all but the last user message)
// are stable across turns, so we mark them for caching to reduce costs.
const CACHE_CONTROL = { anthropic: { cacheControl: { type: "ephemeral" as const } } };
const coreMessages: CoreMessage[] = [];
// System prompt as a cacheable message
if (systemPrompt) {
coreMessages.push({
role: "system",
content: systemPrompt,
providerOptions: CACHE_CONTROL
});
}
// Conversation messages
for (let i = 0; i < chatMessages.length; i++) {
const m = chatMessages[i];
const isLastBeforeNewTurn = i === chatMessages.length - 2;
coreMessages.push({
role: m.role as "user" | "assistant",
content: m.content,
// Cache breakpoint on the second-to-last message:
// everything up to here is identical across consecutive turns.
...(isLastBeforeNewTurn && { providerOptions: CACHE_CONTROL })
});
}
const model = this.anthropic(config.model || DEFAULT_MODEL);
// Build options for streamText
const streamOptions: Parameters<typeof streamText>[0] = {
model,
messages: coreMessages,
maxOutputTokens: config.maxTokens || DEFAULT_MAX_TOKENS
};
// Enable extended thinking for deeper reasoning
if (config.enableExtendedThinking) {
const thinkingBudget = config.thinkingBudget || 10000;
streamOptions.providerOptions = {
anthropic: {
thinking: {
type: "enabled",
budgetTokens: thinkingBudget
}
}
};
streamOptions.maxOutputTokens = Math.max(
streamOptions.maxOutputTokens || DEFAULT_MAX_TOKENS,
thinkingBudget + 4000
);
}
// Build tools object
const tools: ToolSet = {};
if (config.enableWebSearch) {
tools.web_search = this.anthropic.tools.webSearch_20250305({
maxUses: 5
});
}
if (config.contextNoteId) {
Object.assign(tools, currentNoteTools(config.contextNoteId));
}
if (config.enableNoteTools) {
Object.assign(tools, noteTools);
Object.assign(tools, attributeTools);
}
if (Object.keys(tools).length > 0) {
streamOptions.tools = tools;
// Allow multiple tool use cycles before final response
streamOptions.stopWhen = stepCountIs(5);
// Let model decide when to use tools vs respond with text
streamOptions.toolChoice = "auto";
}
return streamText(streamOptions);
}
getModelPricing(model: string): ModelPricing | undefined {
return MODEL_PRICING[model];
}
getAvailableModels(): ModelInfo[] {
return AVAILABLE_MODELS;
}
async generateTitle(firstMessage: string): Promise<string> {
const { text } = await generateText({
model: this.anthropic(TITLE_MODEL),
maxOutputTokens: TITLE_MAX_TOKENS,
messages: [
{
role: "user",
content: `Summarize the following message as a very short chat title (max 6 words). Reply with ONLY the title, no quotes or punctuation at the end.\n\nMessage: ${firstMessage}`
}
]
});
return text.trim();
}
}

View File

@@ -1,106 +0,0 @@
/**
* Shared streaming utilities for converting AI SDK streams to SSE chunks.
*/
import type { LlmStreamChunk } from "@triliumnext/commons";
import type { ModelPricing, StreamResult } from "./types.js";
/**
* Calculate estimated cost in USD based on token usage and pricing.
*/
function calculateCost(inputTokens: number, outputTokens: number, pricing?: ModelPricing): number | undefined {
if (!pricing) return undefined;
const inputCost = (inputTokens / 1_000_000) * pricing.input;
const outputCost = (outputTokens / 1_000_000) * pricing.output;
return inputCost + outputCost;
}
export interface StreamOptions {
/** Model identifier for display */
model?: string;
/** Model pricing for cost calculation (from provider) */
pricing?: ModelPricing;
}
/**
* Convert an AI SDK StreamResult to an async iterable of LlmStreamChunk.
* This is provider-agnostic - works with any AI SDK provider.
*/
export async function* streamToChunks(result: StreamResult, options: StreamOptions = {}): AsyncIterable<LlmStreamChunk> {
try {
for await (const part of result.fullStream) {
switch (part.type) {
case "text-delta":
yield { type: "text", content: part.text };
break;
case "reasoning-delta":
yield { type: "thinking", content: part.text };
break;
case "tool-call":
yield {
type: "tool_use",
toolName: part.toolName,
toolInput: part.input as Record<string, unknown>
};
break;
case "tool-result": {
const output = part.output;
const isError = typeof output === "object" && output !== null && "error" in output;
yield {
type: "tool_result",
toolName: part.toolName,
result: typeof output === "string"
? output
: JSON.stringify(output),
isError
};
break;
}
case "source":
// Citation from web search (only URL sources have url property)
if (part.sourceType === "url") {
yield {
type: "citation",
citation: {
url: part.url,
title: part.title
}
};
}
break;
case "error":
yield { type: "error", error: String(part.error) };
break;
}
}
// Get usage information after stream completes
const usage = await result.usage;
if (usage && typeof usage.inputTokens === "number" && typeof usage.outputTokens === "number") {
const cost = calculateCost(usage.inputTokens, usage.outputTokens, options.pricing);
yield {
type: "usage",
usage: {
promptTokens: usage.inputTokens,
completionTokens: usage.outputTokens,
totalTokens: usage.inputTokens + usage.outputTokens,
cost,
model: options.model
}
};
}
yield { type: "done" };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
yield { type: "error", error: message };
}
}

View File

@@ -1,137 +0,0 @@
/**
* LLM tools for attribute operations (get, set, delete labels/relations).
*/
import { tool } from "ai";
import { z } from "zod";
import becca from "../../../becca/becca.js";
import attributeService from "../../attributes.js";
/**
* Get all owned attributes (labels/relations) of a note.
*/
export const getAttributes = tool({
description: "Get all attributes (labels and relations) of a note. Labels store text values; relations link to other notes by ID.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return note.getOwnedAttributes()
.filter((attr) => !attr.isAutoLink())
.map((attr) => ({
attributeId: attr.attributeId,
type: attr.type,
name: attr.name,
value: attr.value,
isInheritable: attr.isInheritable
}));
}
});
/**
* Get a single attribute by its ID.
*/
export const getAttribute = tool({
description: "Get a single attribute by its ID.",
inputSchema: z.object({
attributeId: z.string().describe("The ID of the attribute")
}),
execute: async ({ attributeId }) => {
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return { error: "Attribute not found" };
}
return {
attributeId: attribute.attributeId,
noteId: attribute.noteId,
type: attribute.type,
name: attribute.name,
value: attribute.value,
isInheritable: attribute.isInheritable
};
}
});
/**
* Add or update an attribute on a note.
*/
export const setAttribute = tool({
description: "Add or update an attribute on a note. If an attribute with the same type and name exists, it is updated; otherwise a new one is created. Use type 'label' for text values, 'relation' for linking to another note (value must be a noteId).",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note"),
type: z.enum(["label", "relation"]).describe("The attribute type"),
name: z.string().describe("The attribute name"),
value: z.string().optional().describe("The attribute value (for relations, this must be a target noteId)")
}),
execute: async ({ noteId, type, name, value = "" }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (note.isProtected) {
return { error: "Note is protected and cannot be modified" };
}
if (attributeService.isAttributeDangerous(type, name)) {
return { error: `Attribute '${name}' is potentially dangerous and cannot be set by the LLM` };
}
if (type === "relation" && value && !becca.getNote(value)) {
return { error: "Target note not found for relation" };
}
note.setAttribute(type, name, value);
return {
success: true,
noteId: note.noteId,
type,
name,
value
};
}
});
/**
* Remove an attribute from a note.
*/
export const deleteAttribute = tool({
description: "Remove an attribute from a note by its attribute ID.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note that owns the attribute"),
attributeId: z.string().describe("The ID of the attribute to delete")
}),
execute: async ({ noteId, attributeId }) => {
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return { error: "Attribute not found" };
}
if (attribute.noteId !== noteId) {
return { error: "Attribute does not belong to the specified note" };
}
const note = becca.getNote(noteId);
if (note?.isProtected) {
return { error: "Note is protected and cannot be modified" };
}
attribute.markAsDeleted();
return {
success: true,
attributeId
};
}
});
export const attributeTools = {
get_attributes: getAttributes,
get_attribute: getAttribute,
set_attribute: setAttribute,
delete_attribute: deleteAttribute
};

View File

@@ -1,7 +0,0 @@
/**
* LLM tools that wrap existing Trilium services.
* These reuse the same logic as ETAPI without any HTTP overhead.
*/
export { noteTools, currentNoteTools } from "./note_tools.js";
export { attributeTools } from "./attribute_tools.js";

View File

@@ -1,246 +0,0 @@
/**
* LLM tools for note operations (search, read, create, update, append).
*/
import { tool } from "ai";
import { z } from "zod";
import becca from "../../../becca/becca.js";
import markdownExport from "../../export/markdown.js";
import markdownImport from "../../import/markdown.js";
import noteService from "../../notes.js";
import SearchContext from "../../search/search_context.js";
import searchService from "../../search/services/search.js";
/**
* Convert note content to a format suitable for LLM consumption.
* Text notes are converted from HTML to Markdown to reduce token usage.
*/
export function getNoteContentForLlm(note: { type: string; getContent: () => string | Buffer }) {
const content = note.getContent();
if (typeof content !== "string") {
return "[binary content]";
}
if (note.type === "text") {
return markdownExport.toMarkdown(content);
}
return content;
}
/**
* Convert LLM-provided content to a format suitable for storage.
* For text notes, converts Markdown to HTML.
*/
function setNoteContentFromLlm(note: { type: string; title: string; setContent: (content: string) => void }, content: string) {
if (note.type === "text") {
note.setContent(markdownImport.renderToHtml(content, note.title));
} else {
note.setContent(content);
}
}
/**
* Search for notes in the knowledge base.
*/
export const searchNotes = tool({
description: "Search for notes in the user's knowledge base. Returns note metadata including title, type, and IDs.",
inputSchema: z.object({
query: z.string().describe("Search query (supports Trilium search syntax)")
}),
execute: async ({ query }) => {
const searchContext = new SearchContext({});
const results = searchService.findResultsWithQuery(query, searchContext);
return results.slice(0, 10).map(sr => {
const note = becca.notes[sr.noteId];
if (!note) return null;
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type
};
}).filter(Boolean);
}
});
/**
* Read the content of a specific note.
*/
export const readNote = tool({
description: "Read the full content of a note by its ID. Use search_notes first to find relevant note IDs. Text notes are returned as Markdown.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to read")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected" };
}
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type,
content: getNoteContentForLlm(note)
};
}
});
/**
* Update the content of a note.
*/
export const updateNoteContent = tool({
description: "Replace the entire content of a note. Use this to completely rewrite a note's content. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to update"),
content: z.string().describe("The new content for the note (Markdown for text notes, plain text for code notes)")
}),
execute: async ({ noteId, content }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected and cannot be modified" };
}
if (!note.hasStringContent()) {
return { error: `Cannot update content for note type: ${note.type}` };
}
note.saveRevision();
setNoteContentFromLlm(note, content);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
});
/**
* Append content to a note.
*/
export const appendToNote = tool({
description: "Append content to the end of an existing note. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to append to"),
content: z.string().describe("The content to append (Markdown for text notes, plain text for code notes)")
}),
execute: async ({ noteId, content }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected and cannot be modified" };
}
if (!note.hasStringContent()) {
return { error: `Cannot update content for note type: ${note.type}` };
}
const existingContent = note.getContent();
if (typeof existingContent !== "string") {
return { error: "Note has binary content" };
}
let newContent: string;
if (note.type === "text") {
const htmlToAppend = markdownImport.renderToHtml(content, note.getTitleOrProtected());
newContent = existingContent + htmlToAppend;
} else {
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
}
note.saveRevision();
note.setContent(newContent);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
});
/**
* Create a new note.
*/
export const createNote = tool({
description: "Create a new note in the user's knowledge base. Returns the created note's ID and title.",
inputSchema: z.object({
parentNoteId: z.string().describe("The ID of the parent note where the new note will be created. Use 'root' for top-level notes."),
title: z.string().describe("The title of the new note"),
content: z.string().describe("The content of the note (Markdown for text notes, plain text for code notes)"),
type: z.enum(["text", "code"]).optional().describe("The type of note to create. Defaults to 'text'.")
}),
execute: async ({ parentNoteId, title, content, type = "text" }) => {
const parentNote = becca.getNote(parentNoteId);
if (!parentNote) {
return { error: "Parent note not found" };
}
if (!parentNote.isContentAvailable()) {
return { error: "Cannot create note under a protected parent" };
}
const htmlContent = type === "text"
? markdownImport.renderToHtml(content, title)
: content;
try {
const { note } = noteService.createNewNote({
parentNoteId,
title,
content: htmlContent,
type
});
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type
};
} catch (err) {
return { error: err instanceof Error ? err.message : "Failed to create note" };
}
}
});
/**
* Read the content of the note the user is currently viewing.
* Created dynamically so it captures the contextNoteId.
*/
export function currentNoteTools(contextNoteId: string) {
return {
get_current_note: tool({
description: "Read the content of the note the user is currently viewing. Call this when the user asks about or refers to their current note.",
inputSchema: z.object({}),
execute: async () => {
const note = becca.getNote(contextNoteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected" };
}
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type,
content: getNoteContentForLlm(note)
};
}
})
};
}
export const noteTools = {
search_notes: searchNotes,
read_note: readNote,
update_note_content: updateNoteContent,
append_to_note: appendToNote,
create_note: createNote
};

View File

@@ -1,80 +0,0 @@
/**
* Server-specific LLM Provider types.
* Shared types (LlmMessage, LlmCitation, LlmStreamChunk, LlmChatConfig)
* should be imported from @triliumnext/commons.
*/
import type { LlmChatConfig, LlmMessage } from "@triliumnext/commons";
import type { streamText } from "ai";
/**
* Extended provider config with server-specific options.
*/
export interface LlmProviderConfig extends LlmChatConfig {
maxTokens?: number;
temperature?: number;
}
/**
* Result type from streamText - the AI SDK's unified streaming interface.
*/
export type StreamResult = ReturnType<typeof streamText>;
/**
* Pricing per million tokens for a model.
*/
export interface ModelPricing {
/** Cost per million input tokens in USD */
input: number;
/** Cost per million output tokens in USD */
output: number;
}
/**
* Information about an available model.
*/
export interface ModelInfo {
/** Model identifier (e.g., "claude-sonnet-4-20250514") */
id: string;
/** Human-readable name (e.g., "Claude Sonnet 4") */
name: string;
/** Pricing per million tokens */
pricing: ModelPricing;
/** Whether this is the default model */
isDefault?: boolean;
/** Cost multiplier relative to the cheapest model (1x = cheapest) */
costMultiplier?: number;
/** Maximum context window size in tokens */
contextWindow?: number;
/** Whether this is a legacy/older model */
isLegacy?: boolean;
}
export interface LlmProvider {
name: string;
/**
* Create a streaming chat completion.
* Returns the AI SDK StreamResult which is provider-agnostic.
*/
chat(
messages: LlmMessage[],
config: LlmProviderConfig
): StreamResult;
/**
* Get pricing for a model. Returns undefined if pricing is not available.
*/
getModelPricing(model: string): ModelPricing | undefined;
/**
* Get list of available models for this provider.
*/
getAvailableModels(): ModelInfo[];
/**
* Generate a short title summarizing a message.
* Used for auto-renaming chat notes. Should use a fast, cheap model.
*/
generateTitle(firstMessage: string): Promise<string>;
}

View File

@@ -15,8 +15,7 @@ const noteTypes = [
{ type: "doc", defaultMime: "" },
{ type: "contentWidget", defaultMime: "" },
{ type: "mindMap", defaultMime: "application/json" },
{ type: "spreadsheet", defaultMime: "application/json" },
{ type: "llmChat", defaultMime: "application/json" }
{ type: "spreadsheet", defaultMime: "application/json" }
];
function getDefaultMimeForNoteType(typeName: string) {

View File

@@ -209,10 +209,7 @@ const defaultOptions: DefaultOption[] = [
]),
isSynced: true
},
{ name: "experimentalFeatures", value: "[]", isSynced: true },
// AI / LLM
{ name: "llmProviders", value: "[]", isSynced: false }
{ name: "experimentalFeatures", value: "[]", isSynced: true }
];
/**

View File

@@ -62,6 +62,9 @@ async function exec<T>(opts: ExecOpts): Promise<T> {
if (opts.auth) {
headers["trilium-cred"] = Buffer.from(`dummy:${opts.auth.password}`).toString("base64");
if (opts.auth.totpToken) {
headers["trilium-totp"] = opts.auth.totpToken;
}
}
const request = (await client).request({

View File

@@ -14,6 +14,7 @@ export interface ExecOpts {
cookieJar?: CookieJar;
auth?: {
password?: string;
totpToken?: string;
};
timeout: number;
body?: string | {};

View File

@@ -1,13 +1,13 @@
import syncService from "./sync.js";
import log from "./log.js";
import sqlInit from "./sql_init.js";
import optionService from "./options.js";
import syncOptions from "./sync_options.js";
import request from "./request.js";
import appInfo from "./app_info.js";
import { timeLimit } from "./utils.js";
import becca from "../becca/becca.js";
import type { SetupStatusResponse, SetupSyncSeedResponse } from "./api-interface.js";
import appInfo from "./app_info.js";
import log from "./log.js";
import optionService from "./options.js";
import request from "./request.js";
import sqlInit from "./sql_init.js";
import syncService from "./sync.js";
import syncOptions from "./sync_options.js";
import { timeLimit } from "./utils.js";
async function hasSyncServerSchemaAndSeed() {
const response = await requestToSyncServer<SetupStatusResponse>("GET", "/api/setup/status");
@@ -55,13 +55,13 @@ async function requestToSyncServer<T>(method: string, path: string, body?: strin
url: syncOptions.getSyncServerHost() + path,
body,
proxy: syncOptions.getSyncProxy(),
timeout: timeout
timeout
}),
timeout
)) as T;
}
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string) {
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string, totpToken?: string) {
if (sqlInit.isDbInitialized()) {
return {
result: "failure",
@@ -76,7 +76,7 @@ async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string
const resp = await request.exec<SetupSyncSeedResponse>({
method: "get",
url: `${syncServerHost}/api/setup/sync-seed`,
auth: { password },
auth: { password, totpToken },
proxy: syncProxy,
timeout: 30000 // seed request should not take long
});
@@ -111,10 +111,30 @@ function getSyncSeedOptions() {
return [becca.getOption("documentId"), becca.getOption("documentSecret")];
}
async function checkRemoteTotpStatus(syncServerHost: string): Promise<{ totpEnabled: boolean }> {
// Validate URL scheme to mitigate SSRF
if (!syncServerHost.startsWith("http://") && !syncServerHost.startsWith("https://")) {
return { totpEnabled: false };
}
try {
const resp = await request.exec<{ totpEnabled?: boolean }>({
method: "get",
url: `${syncServerHost}/api/setup/status`,
proxy: null,
timeout: 10000
});
return { totpEnabled: !!resp?.totpEnabled };
} catch {
return { totpEnabled: false };
}
}
export default {
hasSyncServerSchemaAndSeed,
triggerSync,
sendSeedToSyncServer,
setupSyncFromSyncServer,
getSyncSeedOptions
getSyncSeedOptions,
checkRemoteTotpStatus
};

View File

@@ -10,7 +10,7 @@ import SearchContext from "./search/search_context.js";
import { LBTPL_NOTE_LAUNCHER, LBTPL_CUSTOM_WIDGET, LBTPL_SPACER, LBTPL_SCRIPT } from "./hidden_subtree.js";
import { t } from "i18next";
import BNote from '../becca/entities/bnote.js';
import { SaveSearchNoteResponse, SaveSqlConsoleResponse, SaveLlmChatResponse } from "@triliumnext/commons";
import { SaveSearchNoteResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
function getInboxNote(date: string) {
const workspaceNote = hoistedNoteService.getWorkspaceNote();
@@ -123,114 +123,6 @@ function saveSearchNote(searchNoteId: string) {
return result satisfies SaveSearchNoteResponse;
}
function createLlmChat() {
const { note } = noteService.createNewNote({
parentNoteId: getMonthlyParentNoteId("_llmChat", "llmChat"),
title: `${t("special_notes.llm_chat_prefix")} ${dateUtils.localNowDateTime()}`,
content: JSON.stringify({
version: 1,
messages: []
}),
type: "llmChat",
mime: "application/json"
});
note.setLabel("iconClass", "bx bx-message-square-dots");
note.setLabel("keepCurrentHoisting");
return note;
}
/**
* Gets the most recently modified LLM chat note.
* Used by sidebar chat to persist conversation across page refreshes.
* Returns null if no chat exists.
*/
function getMostRecentLlmChat() {
// Search for all llmChat notes and return the most recently modified
const results = searchService.searchNotes(
"note.type = llmChat",
new SearchContext({
ancestorNoteId: "_llmChat",
limit: 1,
orderBy: "utcDateModified",
orderDirection: "desc"
})
);
return results.length > 0 ? results[0] : null;
}
/**
* Gets the most recent LLM chat or creates a new one if none exists.
* Used by sidebar chat for persistent conversations.
*/
function getOrCreateLlmChat() {
const existingChat = getMostRecentLlmChat();
if (existingChat) {
return existingChat;
}
return createLlmChat();
}
/**
* Gets a list of recent LLM chat notes.
* Used by sidebar chat history popup.
*/
function getRecentLlmChats(limit: number = 10) {
const results = searchService.searchNotes(
"note.type = llmChat",
new SearchContext({
ancestorNoteId: "_llmChat",
limit,
orderBy: "utcDateModified",
orderDirection: "desc"
})
);
return results.map(note => ({
noteId: note.noteId,
title: note.title,
dateModified: note.utcDateModified
}));
}
function getLlmChatHome() {
const workspaceNote = hoistedNoteService.getWorkspaceNote();
if (!workspaceNote) {
throw new Error("Unable to find workspace note");
}
if (!workspaceNote.isRoot()) {
return workspaceNote.searchNoteInSubtree("#workspaceLlmChatHome") || workspaceNote.searchNoteInSubtree("#llmChatHome") || workspaceNote;
} else {
const today = dateUtils.localNowDate();
return workspaceNote.searchNoteInSubtree("#llmChatHome") || dateNoteService.getDayNote(today);
}
}
function saveLlmChat(llmChatNoteId: string) {
const llmChatNote = becca.getNote(llmChatNoteId);
if (!llmChatNote) {
throw new Error(`Unable to find LLM chat note ID: ${llmChatNoteId}`);
}
const llmChatHome = getLlmChatHome();
const result = llmChatNote.cloneTo(llmChatHome.noteId);
for (const parentBranch of llmChatNote.getParentBranches()) {
if (parentBranch.parentNote?.hasAncestor("_hidden")) {
parentBranch.markAsDeleted();
}
}
return result satisfies SaveLlmChatResponse;
}
function getMonthlyParentNoteId(rootNoteId: string, prefix: string) {
const month = dateUtils.localNowDate().substring(0, 7);
const labelName = `${prefix}MonthNote`;
@@ -390,11 +282,6 @@ export default {
saveSqlConsole,
createSearchNote,
saveSearchNote,
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats,
saveLlmChat,
createLauncher,
resetLauncher,
createOrUpdateScriptLauncherFromApi

View File

@@ -1,6 +1,7 @@
import { Totp, generateSecret } from 'time2fa';
import options from './options.js';
import { generateSecret,Totp } from 'time2fa';
import totpEncryptionService from './encryption/totp_encryption.js';
import options from './options.js';
function isTotpEnabled(): boolean {
return options.getOptionOrNull('mfaEnabled') === "true" &&
@@ -10,7 +11,7 @@ function isTotpEnabled(): boolean {
function createSecret(): { success: boolean; message?: string } {
try {
const secret = generateSecret();
const secret = generateSecret(20);
totpEncryptionService.setTotpSecret(secret);
@@ -43,6 +44,8 @@ function validateTOTP(submittedPasscode: string): boolean {
return Totp.validate({
passcode: submittedPasscode,
secret: secret.trim()
}, {
secretSize: secret.trim().length === 32 ? 20 : 10
});
} catch (e) {
console.error('Failed to validate TOTP:', e);

View File

@@ -13,7 +13,7 @@
"postinstall": "wxt prepare"
},
"keywords": [],
"packageManager": "pnpm@10.33.0",
"packageManager": "pnpm@10.32.1",
"devDependencies": {
"@wxt-dev/auto-icons": "1.1.1",
"wxt": "0.20.20"

View File

@@ -9,21 +9,21 @@
"preview": "pnpm build && vite preview"
},
"dependencies": {
"i18next": "25.10.10",
"i18next": "25.8.18",
"i18next-http-backend": "3.0.2",
"preact": "10.29.0",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.6",
"react-i18next": "17.0.0"
"react-i18next": "16.5.8"
},
"devDependencies": {
"@preact/preset-vite": "2.10.5",
"eslint": "10.1.0",
"@preact/preset-vite": "2.10.4",
"eslint": "10.0.3",
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.3",
"vite": "8.0.3",
"vitest": "4.1.2"
"user-agent-data-types": "0.4.2",
"vite": "8.0.0",
"vitest": "4.1.0"
},
"eslintConfig": {
"extends": "preact"

View File

@@ -201,7 +201,7 @@
"resources": {
"title": "Risorse",
"icon_packs": "Pacchetti di icone",
"icon_packs_intro": "Ampliate la selezione di icone disponibili per le vostre note utilizzando un pacchetto di icone. Per ulteriori informazioni sui pacchetti di icone, consultate la <DocumentationLink>documentazione ufficiale</DocumentationLink>.",
"icon_packs_intro": "Ampliate la selezione di icone disponibili per le vostre note utilizzando un pacchetto di icone. Per ulteriori informazioni sui pacchetti di icone, consultate la<DocumentationLink>documentazione ufficiale</DocumentationLink>.",
"download": "Scarica",
"website": "Sito web"
}

View File

@@ -201,7 +201,7 @@
"resources": {
"title": "Zasoby",
"icon_packs": "Paczki ikon",
"icon_packs_intro": "Rozszerz wybór dostępnych ikon dla swoich notatek, korzystając z pakietu ikon. Więcej informacji na temat pakietów ikon znajdziesz w <DocumentationLink> oficjalnej dokumentacji </DocumentationLink>.",
"icon_packs_intro": "Rozszerz wybór dostępnych ikon dla swoich notatek, korzystając z pakietu ikon. Więcej informacji na temat pakietów ikon znajdziesz w <DocumentationLink> dokumentacji </DocumentationLink>.",
"download": "Pobieranie",
"website": "Strona internetowa"
}

2
docs/README-pl.md vendored
View File

@@ -48,7 +48,7 @@ wiedzy.
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
Nasza dokumentacja jest dostępna w wielu formatach:
- **Dokumentacja online**: Przeglądaj pełną dokumentację pod linkiem
- **Dokumentacja Online**: Pełna dokumentacja dostępna na
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **Pomoc w aplikacji**: Naciśnij `F1` w Trilium, aby uzyskać dostęp do tej
samej dokumentacji bezpośrednio w aplikacji

17
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1774171785,
"narHash": "sha256-upDSNdH1WEL2Z0ISvRXTWk7rEndTxUcaTOLY9imJYa8=",
"lastModified": 1769184885,
"narHash": "sha256-wVX5Cqpz66SINNsmt3Bv/Ijzzfl8EPUISq5rKK129K0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f8a13215c766347f3da9beef4cfc952eb23fa46e",
"rev": "12689597ba7a6d776c3c979f393896be095269d4",
"type": "github"
},
"original": {
@@ -43,16 +43,15 @@
]
},
"locked": {
"lastModified": 1774171918,
"narHash": "sha256-0OBrtBnowvYP/YMKh7GB1GX22ORK+2X771EVgT+1tsk=",
"owner": "TriliumNext",
"lastModified": 1749022118,
"narHash": "sha256-7Qzmy1snKbxFBKoqUrfyxxmEB8rPxDdV7PQwRiAR01o=",
"owner": "FliegendeWurst",
"repo": "pnpm2nix-nzbr",
"rev": "536d67261ffe7c91cb286c8581cc799a1b61e969",
"rev": "35f88a41d29839b3989f31871263451c8e092cb1",
"type": "github"
},
"original": {
"owner": "TriliumNext",
"ref": "fix/optional_dependencies_filtering",
"owner": "FliegendeWurst",
"repo": "pnpm2nix-nzbr",
"type": "github"
}

View File

@@ -5,7 +5,7 @@
nixpkgs.url = "github:NixOS/nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
pnpm2nix = {
url = "github:TriliumNext/pnpm2nix-nzbr/fix/optional_dependencies_filtering";
url = "github:FliegendeWurst/pnpm2nix-nzbr";
inputs = {
flake-utils.follows = "flake-utils";
nixpkgs.follows = "nixpkgs";
@@ -325,8 +325,6 @@
buildInputs = [
nodejs
pnpm
electron
nodejs.python
];
};
}

Some files were not shown because too many files have changed in this diff Show More