Merge branch 'develop' of https://github.com/TriliumNext/Notes into develop

This commit is contained in:
Elian Doran
2025-06-06 23:26:34 +03:00
246 changed files with 16097 additions and 4724 deletions

View File

@@ -0,0 +1,6 @@
TRILIUM_ENV=dev
TRILIUM_DATA_DIR=./apps/server/spec/db
TRILIUM_RESOURCE_DIR=./apps/server/dist
TRILIUM_PUBLIC_SERVER=http://localhost:4200
TRILIUM_PORT=8086
TRILIUM_INTEGRATION_TEST=edit

View File

@@ -23,7 +23,7 @@
"@types/ini": "4.1.1",
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.7",
"@types/mime-types": "2.1.4",
"@types/mime-types": "3.0.0",
"@types/multer": "1.4.12",
"@types/safe-compare": "1.1.2",
"@types/sanitize-html": "2.16.0",
@@ -39,7 +39,7 @@
"@types/ws": "8.18.1",
"@types/xml2js": "0.4.14",
"express-http-proxy": "2.1.1",
"@anthropic-ai/sdk": "0.52.0",
"@anthropic-ai/sdk": "0.53.0",
"@braintree/sanitize-url": "7.1.1",
"@triliumnext/commons": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*",
@@ -59,7 +59,7 @@
"debounce": "2.2.0",
"debug": "4.4.1",
"ejs": "3.1.10",
"electron": "36.3.2",
"electron": "36.4.0",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
@@ -85,10 +85,10 @@
"jsdom": "26.1.0",
"marked": "15.0.12",
"mime-types": "3.0.1",
"multer": "2.0.0",
"multer": "2.0.1",
"normalize-strings": "1.1.1",
"ollama": "0.5.16",
"openai": "4.104.0",
"openai": "5.1.1",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.3",
@@ -129,6 +129,23 @@
"runBuildTargetDependencies": false
}
},
"edit-integration-db": {
"executor": "@nx/js:node",
"dependsOn": [
{
"projects": [
"client"
],
"target": "serve"
},
"build-without-client"
],
"continuous": true,
"options": {
"buildTarget": "server:build-without-client:development",
"runBuildTargetDependencies": false
}
},
"package": {
"dependsOn": [
"build"

Binary file not shown.

View File

@@ -0,0 +1,48 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import buildApp from "../../src/app.js";
import supertest from "supertest";
let app: Application;
let token: string;
// TODO: This is an API test, not ETAPI.
describe("api/metrics", () => {
beforeAll(async () => {
app = await buildApp();
});
it("returns Prometheus format by default", async () => {
const response = await supertest(app)
.get("/api/metrics")
.expect(200);
expect(response.headers["content-type"]).toContain("text/plain");
expect(response.text).toContain("trilium_info");
expect(response.text).toContain("trilium_notes_total");
expect(response.text).toContain("# HELP");
expect(response.text).toContain("# TYPE");
});
it("returns JSON when requested", async() => {
const response = await supertest(app)
.get("/api/metrics?format=json")
.expect(200);
expect(response.headers["content-type"]).toContain("application/json");
expect(response.body.version).toBeTruthy();
expect(response.body.database).toBeTruthy();
expect(response.body.timestamp).toBeTruthy();
expect(response.body.database.totalNotes).toBeTypeOf("number");
expect(response.body.database.activeNotes).toBeTypeOf("number");
expect(response.body.noteTypes).toBeTruthy();
expect(response.body.attachmentTypes).toBeTruthy();
expect(response.body.statistics).toBeTruthy();
});
it("returns error on invalid format", async() => {
const response = await supertest(app)
.get("/api/metrics?format=xml")
.expect(500);
expect(response.body.message).toContain("prometheus");
});
});

View File

@@ -0,0 +1,20 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import buildApp from "../../src/app.js";
import supertest from "supertest";
let app: Application;
let token: string;
describe("etapi/app-info", () => {
beforeAll(async () => {
app = await buildApp();
});
it("retrieves correct app info", async () => {
const response = await supertest(app)
.get("/etapi/app-info")
.expect(200);
expect(response.body.clipperProtocolVersion).toBe("1.0");
});
});

View File

@@ -0,0 +1,64 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let createdAttachmentId: string;
describe("etapi/attachment-content", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
// Create an attachment
const response = await supertest(app)
.post(`/etapi/attachments`)
.auth(USER, token, { "type": "basic"})
.send({
"ownerId": createdNoteId,
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
});
createdAttachmentId = response.body.attachmentId;
expect(createdAttachmentId).toBeTruthy();
});
it("changes attachment content", async () => {
const text = "Changed content";
await supertest(app)
.put(`/etapi/attachments/${createdAttachmentId}/content`)
.auth(USER, token, { "type": "basic"})
.set("Content-Type", "text/plain")
.send(text)
.expect(204);
// Ensure it got changed.
const response = await supertest(app)
.get(`/etapi/attachments/${createdAttachmentId}/content`)
.auth(USER, token, { "type": "basic"});
expect(response.text).toStrictEqual(text);
});
it("supports binary content", async() => {
await supertest(app)
.put(`/etapi/attachments/${createdAttachmentId}/content`)
.auth(USER, token, { "type": "basic"})
.set("Content-Type", "application/octet-stream")
.set("Content-Transfer-Encoding", "binary")
.send(Buffer.from("Hello world"))
.expect(204);
});
});

View File

@@ -0,0 +1,54 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
const URL = "/etapi/notes/root";
describe("basic-auth", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("auth token works", async () => {
const response = await supertest(app)
.get(URL)
.auth(USER, token, { "type": "basic"})
.expect(200);
});
it("rejects wrong password", async () => {
const response = await supertest(app)
.get(URL)
.auth(USER, "wrong", { "type": "basic"})
.expect(401);
});
it("rejects wrong user", async () => {
const response = await supertest(app)
.get(URL)
.auth("wrong", token, { "type": "basic"})
.expect(401);
});
it("logs out", async () => {
await supertest(app)
.post("/etapi/auth/logout")
.auth(USER, token, { "type": "basic"})
.expect(204);
// Ensure we can't access it anymore
await supertest(app)
.get("/etapi/notes/root")
.auth(USER, token, { "type": "basic"})
.expect(401);
});
});

View File

@@ -0,0 +1,26 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/backup", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("backup works", async () => {
const response = await supertest(app)
.put("/etapi/backup/etapi_test")
.auth(USER, token, { "type": "basic"})
.expect(204);
});
});

View File

@@ -0,0 +1,178 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
import { randomInt } from "crypto";
let app: Application;
let token: string;
let createdNoteId: string;
let createdBranchId: string;
let clonedBranchId: string;
let createdAttributeId: string;
let createdAttachmentId: string;
const USER = "etapi";
describe("etapi/create-entities", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
({ createdNoteId, createdBranchId } = await createNote());
clonedBranchId = await createClone();
createdAttributeId = await createAttribute();
createdAttachmentId = await createAttachment();
});
it("returns note info", async () => {
const response = await supertest(app)
.get(`/etapi/notes/${createdNoteId}`)
.auth(USER, token, { "type": "basic"})
.send({
noteId: createdNoteId,
parentNoteId: "_hidden"
})
.expect(200);
expect(response.body).toMatchObject({
noteId: createdNoteId,
title: "Hello"
});
expect(new Set<string>(response.body.parentBranchIds))
.toStrictEqual(new Set<string>([ clonedBranchId, createdBranchId ]));
});
it("obtains note content", async () => {
await supertest(app)
.get(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic"})
.expect(200)
.expect("Hi there!");
});
it("obtains created branch information", async () => {
const response = await supertest(app)
.get(`/etapi/branches/${createdBranchId}`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body).toMatchObject({
branchId: createdBranchId,
parentNoteId: "root"
});
});
it("obtains cloned branch information", async () => {
const response = await supertest(app)
.get(`/etapi/branches/${clonedBranchId}`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body).toMatchObject({
branchId: clonedBranchId,
parentNoteId: "_hidden"
});
});
it("obtains attribute information", async () => {
const response = await supertest(app)
.get(`/etapi/attributes/${createdAttributeId}`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body.attributeId).toStrictEqual(createdAttributeId);
});
it("obtains attachment information", async () => {
const response = await supertest(app)
.get(`/etapi/attachments/${createdAttachmentId}`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body.attachmentId).toStrictEqual(createdAttachmentId);
expect(response.body).toMatchObject({
role: "file",
mime: "plain/text",
title: "my attachment"
});
});
});
async function createNote() {
const noteId = `forcedId${randomInt(1000)}`;
const response = await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic"})
.send({
"noteId": noteId,
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!",
"dateCreated": "2023-08-21 23:38:51.123+0200",
"utcDateCreated": "2023-08-21 23:38:51.123Z"
})
.expect(201);
expect(response.body.note.noteId).toStrictEqual(noteId);
expect(response.body).toMatchObject({
note: {
noteId,
title: "Hello",
dateCreated: "2023-08-21 23:38:51.123+0200",
utcDateCreated: "2023-08-21 23:38:51.123Z"
},
branch: {
parentNoteId: "root"
}
});
return {
createdNoteId: response.body.note.noteId,
createdBranchId: response.body.branch.branchId
};
}
async function createClone() {
const response = await supertest(app)
.post("/etapi/branches")
.auth(USER, token, { "type": "basic"})
.send({
noteId: createdNoteId,
parentNoteId: "_hidden"
})
.expect(201);
expect(response.body.parentNoteId).toStrictEqual("_hidden");
return response.body.branchId;
}
async function createAttribute() {
const attributeId = `forcedId${randomInt(1000)}`;
const response = await supertest(app)
.post("/etapi/attributes")
.auth(USER, token, { "type": "basic"})
.send({
"attributeId": attributeId,
"noteId": createdNoteId,
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
})
.expect(201);
expect(response.body.attributeId).toStrictEqual(attributeId);
return response.body.attributeId;
}
async function createAttachment() {
const response = await supertest(app)
.post("/etapi/attachments")
.auth(USER, token, { "type": "basic"})
.send({
"ownerId": createdNoteId,
"role": "file",
"mime": "plain/text",
"title": "my attachment",
"content": "my text"
})
.expect(201);
return response.body.attachmentId;
}

View File

@@ -0,0 +1,172 @@
import { Application } from "express";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
import { randomInt } from "crypto";
let app: Application;
let token: string;
let createdNoteId: string;
let createdBranchId: string;
const USER = "etapi";
type EntityType = "attachments" | "attributes" | "branches" | "notes";
describe("etapi/delete-entities", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
beforeEach(async () => {
({ createdNoteId, createdBranchId } = await createNote());
});
it("deletes attachment", async () => {
const attachmentId = await createAttachment();
await deleteEntity("attachments", attachmentId);
await expectNotFound("attachments", attachmentId);
});
it("deletes attribute", async () => {
const attributeId = await createAttribute();
await deleteEntity("attributes", attributeId);
await expectNotFound("attributes", attributeId);
});
it("deletes cloned branch", async () => {
const clonedBranchId = await createClone();
await expectFound("branches", createdBranchId);
await expectFound("branches", clonedBranchId);
await deleteEntity("branches", createdBranchId);
await expectNotFound("branches", createdBranchId);
await expectFound("branches", clonedBranchId);
await expectFound("notes", createdNoteId);
});
it("deletes note with all branches", async () => {
const attributeId = await createAttribute();
const clonedBranchId = await createClone();
await expectFound("notes", createdNoteId);
await expectFound("branches", createdBranchId);
await expectFound("branches", clonedBranchId);
await expectFound("attributes", attributeId);
await deleteEntity("notes", createdNoteId);
await expectNotFound("branches", createdBranchId);
await expectNotFound("branches", clonedBranchId);
await expectNotFound("notes", createdNoteId);
await expectNotFound("attributes", attributeId);
});
});
async function createNote() {
const noteId = `forcedId${randomInt(1000)}`;
const response = await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic"})
.send({
"noteId": noteId,
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "Hi there!",
"dateCreated": "2023-08-21 23:38:51.123+0200",
"utcDateCreated": "2023-08-21 23:38:51.123Z"
})
.expect(201);
expect(response.body.note.noteId).toStrictEqual(noteId);
return {
createdNoteId: response.body.note.noteId,
createdBranchId: response.body.branch.branchId
};
}
async function createClone() {
const response = await supertest(app)
.post("/etapi/branches")
.auth(USER, token, { "type": "basic"})
.send({
noteId: createdNoteId,
parentNoteId: "_hidden"
})
.expect(201);
expect(response.body.parentNoteId).toStrictEqual("_hidden");
return response.body.branchId;
}
async function createAttribute() {
const attributeId = `forcedId${randomInt(1000)}`;
const response = await supertest(app)
.post("/etapi/attributes")
.auth(USER, token, { "type": "basic"})
.send({
"attributeId": attributeId,
"noteId": createdNoteId,
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
})
.expect(201);
expect(response.body.attributeId).toStrictEqual(attributeId);
return response.body.attributeId;
}
async function createAttachment() {
const response = await supertest(app)
.post("/etapi/attachments")
.auth(USER, token, { "type": "basic"})
.send({
"ownerId": createdNoteId,
"role": "file",
"mime": "plain/text",
"title": "my attachment",
"content": "my text"
})
.expect(201);
return response.body.attachmentId;
}
async function deleteEntity(entity: EntityType, id: string) {
// Delete twice to test idempotency.
for (let i=0; i < 2; i++) {
await supertest(app)
.delete(`/etapi/${entity}/${id}`)
.auth(USER, token, { "type": "basic"})
.expect(204);
}
}
const MISSING_ENTITY_ERROR_CODES: Record<EntityType, string> = {
attachments: "ATTACHMENT_NOT_FOUND",
attributes: "ATTRIBUTE_NOT_FOUND",
branches: "BRANCH_NOT_FOUND",
notes: "NOTE_NOT_FOUND"
}
async function expectNotFound(entity: EntityType, id: string) {
const response = await supertest(app)
.get(`/etapi/${entity}/${id}`)
.auth(USER, token, { "type": "basic"})
.expect(404);
expect(response.body.code).toStrictEqual(MISSING_ENTITY_ERROR_CODES[entity]);
}
async function expectFound(entity: EntityType, id: string) {
await supertest(app)
.get(`/etapi/${entity}/${id}`)
.auth(USER, token, { "type": "basic"})
.expect(200);
}

View File

@@ -0,0 +1,71 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/metrics", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("returns Prometheus format by default", async () => {
const response = await supertest(app)
.get("/etapi/metrics")
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.headers["content-type"]).toContain("text/plain");
expect(response.text).toContain("trilium_info");
expect(response.text).toContain("trilium_notes_total");
expect(response.text).toContain("# HELP");
expect(response.text).toContain("# TYPE");
});
it("returns JSON when requested", async() => {
const response = await supertest(app)
.get("/etapi/metrics?format=json")
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.headers["content-type"]).toContain("application/json");
expect(response.body.version).toBeTruthy();
expect(response.body.database).toBeTruthy();
expect(response.body.timestamp).toBeTruthy();
expect(response.body.database.totalNotes).toBeTypeOf("number");
expect(response.body.database.activeNotes).toBeTypeOf("number");
expect(response.body.noteTypes).toBeTruthy();
expect(response.body.attachmentTypes).toBeTruthy();
expect(response.body.statistics).toBeTruthy();
});
it("returns Prometheus format explicitly", async () => {
const response = await supertest(app)
.get("/etapi/metrics?format=prometheus")
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.headers["content-type"]).toContain("text/plain");
expect(response.text).toContain("trilium_info");
expect(response.text).toContain("trilium_notes_total");
});
it("returns error on invalid format", async() => {
const response = await supertest(app)
.get("/etapi/metrics?format=xml")
.auth(USER, token, { "type": "basic"})
.expect(500);
expect(response.body.message).toContain("prometheus");
});
it("should fail without authentication", async() => {
await supertest(app)
.get("/etapi/metrics")
.expect(401);
});
});

View File

@@ -0,0 +1,51 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/export-note-subtree", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("export works", async () => {
await supertest(app)
.get("/etapi/notes/root/export")
.auth(USER, token, { "type": "basic"})
.expect(200)
.expect("Content-Type", "application/zip");
});
it("HTML export works", async () => {
await supertest(app)
.get("/etapi/notes/root/export?format=html")
.auth(USER, token, { "type": "basic"})
.expect(200)
.expect("Content-Type", "application/zip");
});
it("Markdown export works", async () => {
await supertest(app)
.get("/etapi/notes/root/export?format=markdown")
.auth(USER, token, { "type": "basic"})
.expect(200)
.expect("Content-Type", "application/zip");
});
it("reports wrong format", async () => {
const response = await supertest(app)
.get("/etapi/notes/root/export?format=wrong")
.auth(USER, token, { "type": "basic"})
.expect(400);
expect(response.body.code).toStrictEqual("UNRECOGNIZED_EXPORT_FORMAT");
});
});

View File

@@ -0,0 +1,103 @@
import { beforeAll, describe, expect, it } from "vitest";
import config from "../../src/services/config.js";
import { login } from "./utils.js";
import { Application } from "express";
import supertest from "supertest";
import date_notes from "../../src/services/date_notes.js";
import cls from "../../src/services/cls.js";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/get-date-notes", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("obtains inbox", async () => {
await supertest(app)
.get("/etapi/inbox/2022-01-01")
.auth(USER, token, { "type": "basic"})
.expect(200);
});
describe("days", () => {
it("obtains day from calendar", async () => {
await supertest(app)
.get("/etapi/calendar/days/2022-01-01")
.auth(USER, token, { "type": "basic"})
.expect(200);
});
it("detects invalid date", async () => {
const response = await supertest(app)
.get("/etapi/calendar/days/2022-1")
.auth(USER, token, { "type": "basic"})
.expect(400);
expect(response.body.code).toStrictEqual("DATE_INVALID");
});
});
describe("weeks", () => {
beforeAll(() => {
cls.init(() => {
const rootCalendarNote = date_notes.getRootCalendarNote();
rootCalendarNote.setLabel("enableWeekNote");
});
});
it("obtains week calendar", async () => {
await supertest(app)
.get("/etapi/calendar/weeks/2022-W01")
.auth(USER, token, { "type": "basic"})
.expect(200);
});
it("detects invalid date", async () => {
const response = await supertest(app)
.get("/etapi/calendar/weeks/2022-1")
.auth(USER, token, { "type": "basic"})
.expect(400);
expect(response.body.code).toStrictEqual("WEEK_INVALID");
});
});
describe("months", () => {
it("obtains month calendar", async () => {
await supertest(app)
.get("/etapi/calendar/months/2022-01")
.auth(USER, token, { "type": "basic"})
.expect(200);
});
it("detects invalid month", async () => {
const response = await supertest(app)
.get("/etapi/calendar/months/2022-1")
.auth(USER, token, { "type": "basic"})
.expect(400);
expect(response.body.code).toStrictEqual("MONTH_INVALID");
});
});
describe("years", () => {
it("obtains year calendar", async () => {
await supertest(app)
.get("/etapi/calendar/years/2022")
.auth(USER, token, { "type": "basic"})
.expect(200);
});
it("detects invalid year", async () => {
const response = await supertest(app)
.get("/etapi/calendar/years/202")
.auth(USER, token, { "type": "basic"})
.expect(400);
expect(response.body.code).toStrictEqual("YEAR_INVALID");
});
});
});

View File

@@ -0,0 +1,98 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
let parentNoteId: string;
describe("etapi/get-inherited-attribute-cloned", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
parentNoteId = await createNote(app, token);
});
it("gets inherited attribute", async () => {
// Create an inheritable attribute on the parent note.
let response = await supertest(app)
.post("/etapi/attributes")
.auth("etapi", token, { "type": "basic"})
.send({
"noteId": parentNoteId,
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true,
"position": 10
})
.expect(201);
const parentAttributeId = response.body.attributeId;
expect(parentAttributeId).toBeTruthy();
// Create a subnote.
response = await supertest(app)
.post("/etapi/create-note")
.auth("etapi", token, { "type": "basic"})
.send({
"parentNoteId": parentNoteId,
"title": "Hello",
"type": "text",
"content": "Hi there!"
})
.expect(201);
const childNoteId = response.body.note.noteId;
// Create child attribute
response = await supertest(app)
.post("/etapi/attributes")
.auth("etapi", token, { "type": "basic"})
.send({
"noteId": childNoteId,
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": false,
"position": 10
})
.expect(201);
const childAttributeId = response.body.attributeId;
expect(parentAttributeId).toBeTruthy();
// Clone child to parent
response = await supertest(app)
.post("/etapi/branches")
.auth("etapi", token, { "type": "basic"})
.send({
noteId: childNoteId,
parentNoteId: parentNoteId
})
.expect(200);
parentNoteId = response.body.parentNoteId;
// Check attribute IDs
response = await supertest(app)
.get(`/etapi/notes/${childNoteId}`)
.auth("etapi", token, { "type": "basic"})
.expect(200);
expect(response.body.noteId).toStrictEqual(childNoteId);
expect(response.body.attributes).toHaveLength(2);
expect(hasAttribute(response.body.attributes, parentAttributeId));
expect(hasAttribute(response.body.attributes, childAttributeId));
});
function hasAttribute(list: object[], attributeId: string) {
for (let i = 0; i < list.length; i++) {
if (list[i]["attributeId"] === attributeId) {
return true;
}
}
return false;
}
});

View File

@@ -0,0 +1,60 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
let parentNoteId: string;
describe("etapi/get-inherited-attribute", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
parentNoteId = await createNote(app, token);
});
it("gets inherited attribute", async () => {
// Create an inheritable attribute on the parent note.
let response = await supertest(app)
.post("/etapi/attributes")
.auth("etapi", token, { "type": "basic"})
.send({
"noteId": parentNoteId,
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
})
.expect(201);
const createdAttributeId = response.body.attributeId;
expect(createdAttributeId).toBeTruthy();
// Create a subnote.
response = await supertest(app)
.post("/etapi/create-note")
.auth("etapi", token, { "type": "basic"})
.send({
"parentNoteId": parentNoteId,
"title": "Hello",
"type": "text",
"content": "Hi there!"
})
.expect(201);
const createdNoteId = response.body.note.noteId;
// Check the attribute is inherited.
response = await supertest(app)
.get(`/etapi/notes/${createdNoteId}`)
.auth("etapi", token, { "type": "basic"})
.expect(200);
expect(response.body.noteId).toStrictEqual(createdNoteId);
expect(response.body.attributes).toHaveLength(1);
expect(response.body.attributes[0].attributeId === createdAttributeId);
});
});

View File

@@ -0,0 +1,34 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
import { readFileSync } from "fs";
import { join } from "path";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/import", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("demo zip can be imported", async () => {
const buffer = readFileSync(join(__dirname, "../../src/assets/db/demo.zip"));
const response = await supertest(app)
.post("/etapi/notes/root/import")
.auth(USER, token, { "type": "basic"})
.set("Content-Type", "application/octet-stream")
.set("Content-Transfer-Encoding", "binary")
.send(buffer)
.expect(201);
expect(response.body.note.title).toStrictEqual("Journal");
expect(response.body.branch.parentNoteId).toStrictEqual("root");
});
});

View File

@@ -0,0 +1,54 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
import type TestAgent from "supertest/lib/agent.js";
let app: Application;
const USER = "etapi";
const routes = [
"GET /etapi/notes?search=aaa",
"GET /etapi/notes/root",
"PATCH /etapi/notes/root",
"DELETE /etapi/notes/root",
"GET /etapi/branches/root",
"PATCH /etapi/branches/root",
"DELETE /etapi/branches/root",
"GET /etapi/attributes/000",
"PATCH /etapi/attributes/000",
"DELETE /etapi/attributes/000",
"GET /etapi/inbox/2022-02-22",
"GET /etapi/calendar/days/2022-02-22",
"GET /etapi/calendar/weeks/2022-02-22",
"GET /etapi/calendar/months/2022-02",
"GET /etapi/calendar/years/2022",
"POST /etapi/create-note",
"GET /etapi/app-info",
]
describe("no-token", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
});
for (const route of routes) {
const [ method, url ] = route.split(" ", 2);
it(`rejects access to ${method} ${url}`, () => {
(supertest(app)[method.toLowerCase()](url) as TestAgent)
.auth(USER, "fakeauth", { "type": "basic"})
.expect(401)
});
}
it("responds with 404 even without token", () => {
supertest(app)
.get("/etapi/zzzzzz")
.expect(404);
});
});

View File

@@ -0,0 +1,72 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/note-content", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
});
it("get content", async () => {
const response = await getContentResponse();
expect(response.text).toStrictEqual("Hi there!");
});
it("put note content", async () => {
const text = "Changed content";
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic"})
.set("Content-Type", "text/plain")
.send(text)
.expect(204);
const response = await getContentResponse();
expect(response.text).toStrictEqual(text);
});
it("put note content binary", async () => {
// First, create a binary note
const response = await supertest(app)
.post("/etapi/create-note")
.auth("etapi", token, { "type": "basic"})
.send({
"parentNoteId": "root",
"title": "Hello",
"mime": "image/png",
"type": "image",
"content": ""
})
.expect(201);
const createdNoteId = response.body.note.noteId;
// Put binary content
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic"})
.set("Content-Type", "application/octet-stream")
.set("Content-Transfer-Encoding", "binary")
.send(Buffer.from("Hello world"))
.expect(204);
});
function getContentResponse() {
return supertest(app)
.get(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic"})
.expect(200);
}
});

View File

@@ -0,0 +1,26 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/refresh-note-ordering/root", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("refreshes note ordering", async () => {
await supertest(app)
.post("/etapi/refresh-note-ordering/root")
.auth(USER, token, { "type": "basic"})
.expect(204);
});
});

View File

@@ -0,0 +1,78 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let createdAttachmentId: string;
describe("etapi/attachment-content", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
// Create an attachment
const response = await supertest(app)
.post(`/etapi/attachments`)
.auth(USER, token, { "type": "basic"})
.send({
"ownerId": createdNoteId,
"role": "file",
"mime": "text/plain",
"title": "my attachment",
"content": "text"
});
createdAttachmentId = response.body.attachmentId;
expect(createdAttachmentId).toBeTruthy();
});
it("changes title and position", async () => {
const state = {
title: "CHANGED",
position: 999
}
await supertest(app)
.patch(`/etapi/attachments/${createdAttachmentId}`)
.auth(USER, token, { "type": "basic"})
.send(state)
.expect(200);
// Ensure it got changed.
const response = await supertest(app)
.get(`/etapi/attachments/${createdAttachmentId}`)
.auth(USER, token, { "type": "basic"});
expect(response.body).toMatchObject(state);
});
it("forbids changing owner", async () => {
const response = await supertest(app)
.patch(`/etapi/attachments/${createdAttachmentId}`)
.auth(USER, token, { "type": "basic"})
.send({
ownerId: "root"
})
.expect(400);
expect(response.body.code).toStrictEqual("PROPERTY_NOT_ALLOWED");
});
it("handles validation error", async () => {
const response = await supertest(app)
.patch(`/etapi/attachments/${createdAttachmentId}`)
.auth(USER, token, { "type": "basic"})
.send({
title: null
})
.expect(400);
expect(response.body.code).toStrictEqual("PROPERTY_VALIDATION_ERROR");
});
});

View File

@@ -0,0 +1,77 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let createdAttributeId: string;
describe("etapi/patch-attribute", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
// Create an attribute
const response = await supertest(app)
.post(`/etapi/attributes`)
.auth(USER, token, { "type": "basic"})
.send({
"noteId": createdNoteId,
"type": "label",
"name": "mylabel",
"value": "val",
"isInheritable": true
});
createdAttributeId = response.body.attributeId;
expect(createdAttributeId).toBeTruthy();
});
it("changes name and value", async () => {
const state = {
value: "CHANGED"
};
await supertest(app)
.patch(`/etapi/attributes/${createdAttributeId}`)
.auth(USER, token, { "type": "basic"})
.send(state)
.expect(200);
// Ensure it got changed.
const response = await supertest(app)
.get(`/etapi/attributes/${createdAttributeId}`)
.auth(USER, token, { "type": "basic"});
expect(response.body).toMatchObject(state);
});
it("forbids setting disallowed property", async () => {
const response = await supertest(app)
.patch(`/etapi/attributes/${createdAttributeId}`)
.auth(USER, token, { "type": "basic"})
.send({
noteId: "root"
})
.expect(400);
expect(response.body.code).toStrictEqual("PROPERTY_NOT_ALLOWED");
});
it("forbids setting wrong data type", async () => {
const response = await supertest(app)
.patch(`/etapi/attributes/${createdAttributeId}`)
.auth(USER, token, { "type": "basic"})
.send({
value: null
})
.expect(400);
expect(response.body.code).toStrictEqual("PROPERTY_VALIDATION_ERROR");
});
});

View File

@@ -0,0 +1,77 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdBranchId: string;
describe("etapi/attachment-content", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
// Create a note and a branch.
const response = await supertest(app)
.post("/etapi/create-note")
.auth("etapi", token, { "type": "basic"})
.send({
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": "",
})
.expect(201);
createdBranchId = response.body.branch.branchId;
});
it("can patch branch info", async () => {
const state = {
prefix: "pref",
notePosition: 666,
isExpanded: true
};
await supertest(app)
.patch(`/etapi/branches/${createdBranchId}`)
.auth("etapi", token, { "type": "basic"})
.send(state)
.expect(200);
const response = await supertest(app)
.get(`/etapi/branches/${createdBranchId}`)
.auth("etapi", token, { "type": "basic"})
.expect(200);
expect(response.body).toMatchObject(state);
});
it("rejects not allowed property", async () => {
const response = await supertest(app)
.patch(`/etapi/branches/${createdBranchId}`)
.auth("etapi", token, { "type": "basic"})
.send({
parentNoteId: "root"
})
.expect(400);
expect(response.body.code).toStrictEqual("PROPERTY_NOT_ALLOWED");
});
it("rejects invalid property value", async () => {
const response = await supertest(app)
.patch(`/etapi/branches/${createdBranchId}`)
.auth("etapi", token, { "type": "basic"})
.send({
prefix: 123
})
.expect(400);
expect(response.body.code).toStrictEqual("PROPERTY_VALIDATION_ERROR");
});
});

View File

@@ -0,0 +1,89 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/patch-note", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
const response = await supertest(app)
.post("/etapi/create-note")
.auth("etapi", token, { "type": "basic"})
.send({
"parentNoteId": "root",
"title": "Hello",
"type": "code",
"mime": "application/json",
"content": "{}"
})
.expect(201);
const createdNoteId = response.body.note.noteId as string;
expect(createdNoteId).toBeTruthy();
});
it("obtains correct note information", async () => {
await expectNoteToMatch({
title: "Hello",
type: "code",
mime: "application/json"
});
});
it("patches type, mime and creation dates", async () => {
const changes = {
"title": "Wassup",
"type": "html",
"mime": "text/html",
"dateCreated": "2023-08-21 23:38:51.123+0200",
"utcDateCreated": "2023-08-21 23:38:51.123Z"
};
await supertest(app)
.patch(`/etapi/notes/${createdNoteId}`)
.auth("etapi", token, { "type": "basic"})
.send(changes)
.expect(200);
await expectNoteToMatch(changes);
});
it("refuses setting protection", async () => {
const response = await supertest(app)
.patch(`/etapi/notes/${createdNoteId}`)
.auth("etapi", token, { "type": "basic"})
.send({
isProtected: true
})
.expect(400);
expect(response.body.code).toStrictEqual("PROPERTY_NOT_ALLOWED");
});
it("refuses incorrect type", async () => {
const response = await supertest(app)
.patch(`/etapi/notes/${createdNoteId}`)
.auth("etapi", token, { "type": "basic"})
.send({
title: true
})
.expect(400);
expect(response.body.code).toStrictEqual("PROPERTY_VALIDATION_ERROR");
});
async function expectNoteToMatch(state: object) {
const response = await supertest(app)
.get(`/etapi/notes/${createdNoteId}`)
.auth("etapi", token, { "type": "basic"})
.expect(200);
expect(response.body).toMatchObject(state);
}
});

View File

@@ -0,0 +1,29 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/post-revision", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
});
it("posts note revision", async () => {
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic"})
.send("Changed content")
.expect(204);
});
});

View File

@@ -0,0 +1,40 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
import { randomUUID } from "crypto";
let app: Application;
let token: string;
const USER = "etapi";
let content: string;
describe("etapi/search", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
content = randomUUID();
await createNote(app, token, content);
});
it("finds by content", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${content}&debug=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body.results).toHaveLength(1);
});
it("does not find by content when fast search is on", async () => {
const response = await supertest(app)
.get(`/etapi/notes?search=${content}&debug=true&fastSearch=true`)
.auth(USER, token, { "type": "basic"})
.expect(200);
expect(response.body.results).toHaveLength(0);
});
});

View File

@@ -0,0 +1,33 @@
import type { Application } from "express";
import supertest from "supertest";
import { expect } from "vitest";
export async function login(app: Application) {
// Obtain auth token.
const response = await supertest(app)
.post("/etapi/auth/login")
.send({
"password": "demo1234"
})
.expect(201);
const token = response.body.authToken;
expect(token).toBeTruthy();
return token;
}
export async function createNote(app: Application, token: string, content?: string) {
const response = await supertest(app)
.post("/etapi/create-note")
.auth("etapi", token, { "type": "basic"})
.send({
"parentNoteId": "root",
"title": "Hello",
"type": "text",
"content": content ?? "Hi there!",
})
.expect(201);
const noteId = response.body.note.noteId as string;
expect(noteId).toStrictEqual(noteId);
return noteId;
}

View File

@@ -3,6 +3,13 @@ import i18next from "i18next";
import { join } from "path";
import dayjs from "dayjs";
// Initialize environment variables.
process.env.TRILIUM_DATA_DIR = join(__dirname, "db");
process.env.TRILIUM_RESOURCE_DIR = join(__dirname, "../src");
process.env.TRILIUM_INTEGRATION_TEST = "memory";
process.env.TRILIUM_ENV = "dev";
process.env.TRILIUM_PUBLIC_SERVER = "http://localhost:4200";
beforeAll(async () => {
// Initialize the translations manually to avoid any side effects.
const Backend = (await import("i18next-fs-backend")).default;

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -28,7 +28,7 @@
where you can track your daily weight. This data is then used in <a href="#root/_help_R7abl2fc6Mxi">Weight tracker</a>.</p>
<h2>Week Note and Quarter Note</h2>
<p>Week and quarter notes are disabled by default, since it might be too
much for some people. To enable them, you need to set <code>#enableWeekNotes</code> and <code>#enableQuarterNotes</code> attributes
much for some people. To enable them, you need to set <code>#enableWeekNote</code> and <code>#enableQuarterNote</code> attributes
on the root calendar note, which is identified by <code>#calendarRoot</code> label.
Week note is affected by the first week of year option. Be careful when
you already have some week notes created, it will not automatically change
@@ -40,15 +40,26 @@
(identified by <code>#calendarRoot</code> label):</p>
<ul>
<li>yearTemplate</li>
<li>quarterTemplate (if <code>#enableQuarterNotes</code> is set)</li>
<li>quarterTemplate (if <code>#enableQuarterNote</code> is set)</li>
<li>monthTemplate</li>
<li>weekTemplate (if <code>#enableWeekNotes</code> is set)</li>
<li>weekTemplate (if <code>#enableWeekNote</code> is set)</li>
<li>dateTemplate</li>
</ul>
<p>All of these are relations. When Trilium creates a new note for year or
month or date, it will take a look at the root and attach a corresponding <code>~template</code> relation
to the newly created role. Using this, you can e.g. create your daily template
with e.g. checkboxes for daily routine etc.</p>
<h3>Migrate from old template usage</h3>
<p>If you have been using Journal prior to version v0.93.0, the previous
template pattern likely used was <code>~child:template=</code>.
<br>To transition to the new system:</p>
<ol>
<li>Set up the new template pattern in the Calendar root note.</li>
<li>Use <a href="#root/_help_ivYnonVFBxbQ">Bulk Actions</a> to remove <code>child:template</code> and <code>child:child:template</code> from
all notes under the Journal (calendar root).</li>
<li>Ensure that all old template patterns are fully removed to prevent conflicts
with the new setup.</li>
</ol>
<h2>Naming pattern</h2>
<p>You can customize the title of generated journal notes by defining a <code>#datePattern</code>, <code>#weekPattern</code>, <code>#monthPattern</code>, <code>#quarterPattern</code> and <code>#yearPattern</code> attribute
on a root calendar note (identified by <code>#calendarRoot</code> label).
@@ -138,9 +149,4 @@
<p>Trilium has some special support for day notes in the form of <a href="https://triliumnext.github.io/Notes/backend_api/BackendScriptApi.html">backend Script API</a> -
see e.g. getDayNote() function.</p>
<p>Day (and year, month) notes are created with a label - e.g. <code>#dateNote="2025-03-09"</code> this
can then be used by other scripts to add new notes to day note etc.</p>
<p>Journal also has relation <code>child:child:child:template=Day template</code> (see
[[attribute inheritance]]) which effectively adds [[template]] to day notes
(grand-grand-grand children of Journal). Please note that, when you enable
week notes or quarter notes, it will not automatically change the relation
for the child level.</p>
can then be used by other scripts to add new notes to day note etc.</p>

View File

@@ -79,4 +79,24 @@ trilium_notes_total 1234 1701432000
<li><code>400</code> - Invalid format parameter</li>
<li><code>401</code> - Missing or invalid ETAPI token</li>
<li><code>500</code> - Internal server error</li>
</ul>
</ul>
<p>&nbsp;</p>
<h2><strong>Grafana Dashboard</strong></h2>
<figure class="image">
<img style="aspect-ratio:2594/1568;" src="1_Metrics_image.png" width="2594"
height="1568">
</figure>
<p>&nbsp;</p>
<p>You can also use the Grafana Dashboard that has been created for TriliumNext
- just take the JSON from&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/uYF7pmepw27K/_help_bOP3TB56fL1V">grafana-dashboard.json</a>&nbsp;and
then import the dashboard, following these screenshots:</p>
<figure class="image">
<img style="aspect-ratio:1881/282;" src="2_Metrics_image.png" width="1881"
height="282">
</figure>
<p>Then paste the JSON, and hit load:</p>
<figure class="image">
<img style="aspect-ratio:1055/830;" src="Metrics_image.png" width="1055"
height="830">
</figure>
<p>&nbsp;</p>

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -40,7 +40,19 @@
you can also mark templates with <code>#workspaceTemplate</code> to display
them only in the workspace.</p>
<p>Templates can also be added or changed after note creation by creating
a <code>~template</code> relation pointing to the desired template note.</p>
a <code>~template</code> relation pointing to the desired template note.&nbsp;</p>
<p>To specify a template for child notes, you can use a <code>~child:template</code> relation
pointing to the appropriate template note. There is no limit to the depth
of the hierarchy — you can use <code>~child:child:template</code>, <code>~child:child:child:template</code>,
and so on.</p>
<aside class="admonition important">
<p>Changing the template hierarchy after the parent note is created will
not retroactively apply to newly created child notes.
<br>For example, if you initially use <code>~child:template</code> and later
switch to <code>~child:child:template</code>, it will not automatically
apply the new template to the grandchild notes. Only the structure present
at the time of note creation is considered.</p>
</aside>
<h2>Additional Notes</h2>
<p>From a visual perspective, templates can define <code>#iconClass</code> and <code>#cssClass</code> attributes,
allowing all instance notes (e.g., books) to display a specific icon and

View File

@@ -77,7 +77,7 @@
class="reference-link" href="#root/_help_QrtTYPmdd1qq">Markdown-like formatting</a>.</p>
</aside>
<ul>
<li>&nbsp;<kbd>Enter</kbd> in tree pane switches from tree pane into note title.
<li><kbd>Enter</kbd> in tree pane switches from tree pane into note title.
Enter from note title switches focus to text editor. <kbd>Ctrl</kbd>+<kbd>.</kbd> switches
back from editor to tree pane.</li>
<li><kbd>Ctrl</kbd>+<kbd>.</kbd> - jump away from the editor to tree pane and

View File

@@ -39,6 +39,7 @@
</tbody>
</table>
</figure>
<h2>Repositioning the map</h2>
<ul>
<li>Click and drag the map in order to move across the map.</li>
@@ -102,12 +103,11 @@
</tbody>
</table>
</figure>
<h2>How the location of the markers is stored</h2>
<p>The location of a marker is stored in the <code>#geolocation</code> attribute
of the child notes:</p>
<p>
<img src="18_Geo Map_image.png" width="1288" height="278">
</p>
<img src="18_Geo Map_image.png" width="1288" height="278">
<p>This value can be added manually if needed. The value of the attribute
is made up of the latitude and longitude separated by a comma.</p>
<h2>Repositioning markers</h2>
@@ -210,6 +210,7 @@
</tbody>
</table>
</figure>
<h3>Adding from OpenStreetMap</h3>
<p>Similarly to the Google Maps approach:</p>
<figure class="table" style="width:100%;">
@@ -259,6 +260,7 @@
</tbody>
</table>
</figure>
<h2>Adding GPS tracks (.gpx)</h2>
<p>Trilium has basic support for displaying GPS tracks on the geo map.</p>
<figure
@@ -327,7 +329,8 @@ class="table" style="width:100%;">
<img style="aspect-ratio:678/499;" src="14_Geo Map_image.png" width="678"
height="499">
</figure>
<h3>Grid-like artifacts on the map</h3>
<h3>Grid-like artifacts on the map</h3>
<p>This occurs if the application is not at 100% zoom which causes the pixels
of the map to not render correctly due to fractional scaling. The only
possible solution is to set the UI zoom at 100% (default keyboard shortcut

View File

@@ -60,7 +60,7 @@
</td>
</tr>
<tr>
<td>Mark selected text as <a href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_UYuUB1ZekNQU">keyboard shortcut</a>
<td>Mark selected text as <a href="#root/_help_UYuUB1ZekNQU">keyboard shortcut</a>
</td>
<td><kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>K</kbd>
</td>
@@ -68,7 +68,7 @@
</td>
</tr>
<tr>
<td>Insert&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_YfYAtQBcfo5V">Math Equations</a>
<td>Insert&nbsp;<a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</td>
<td><kbd>Ctrl</kbd> + <kbd>M</kbd>
</td>
@@ -76,7 +76,7 @@
</td>
</tr>
<tr>
<td rowspan="2">Move blocks (lists, paragraphs, etc.) up</td>
<td>Move blocks (lists, paragraphs, etc.) up</td>
<td><kbd>Ctrl</kbd>+<kbd></kbd>&nbsp;</td>
<td><kbd></kbd>+<kbd></kbd>&nbsp;</td>
</tr>
@@ -87,7 +87,7 @@
</td>
</tr>
<tr>
<td rowspan="2">Move blocks (lists, paragraphs, etc.) down</td>
<td>Move blocks (lists, paragraphs, etc.) down</td>
<td><kbd>Ctrl</kbd>+<kbd></kbd>
</td>
<td><kbd></kbd>+<kbd></kbd>
@@ -102,6 +102,7 @@
</tbody>
</table>
</figure>
<h2>Common shortcuts</h2>
<aside class="admonition tip">
<p>This section of keyboard shortcuts presents a subset of the keyboard shortcuts
@@ -260,6 +261,7 @@
</tbody>
</table>
</figure>
<h3>Interacting with blocks</h3>
<p>Blocks are images, tables, blockquotes, annotations.</p>
<figure class="table">
@@ -373,6 +375,7 @@
</tbody>
</table>
</figure>
<h3>General UI shortcuts</h3>
<figure class="table">
<table>

View File

@@ -1,12 +1,18 @@
{
"keyboard_actions": {
"back-in-note-history": "Navigate to previous note in history",
"forward-in-note-history": "Navigate to next note in history",
"open-jump-to-note-dialog": "Open \"Jump to note\" dialog",
"scroll-to-active-note": "Scroll note tree to active note",
"quick-search": "Activate quick search bar",
"search-in-subtree": "Search for notes in the active note's subtree",
"expand-subtree": "Expand subtree of current note",
"collapse-tree": "Collapses the complete note tree",
"collapse-subtree": "Collapses subtree of current note",
"sort-child-notes": "Sort child notes",
"creating-and-moving-notes": "Creating and moving notes",
"create-note-after": "Create note after active note",
"create-note-into": "Create note as child of active note",
"create-note-into-inbox": "Create a note in the inbox (if defined) or day note",
"delete-note": "Delete note",
"move-note-up": "Move note up",
@@ -14,40 +20,44 @@
"move-note-up-in-hierarchy": "Move note up in hierarchy",
"move-note-down-in-hierarchy": "Move note down in hierarchy",
"edit-note-title": "Jump from tree to the note detail and edit title",
"edit-branch-prefix": "Show Edit branch prefix dialog",
"edit-branch-prefix": "Show \"Edit branch prefix\" dialog",
"cloneNotesTo": "Clone selected notes",
"moveNotesTo": "Move selected notes",
"note-clipboard": "Note clipboard",
"copy-notes-to-clipboard": "Copy selected notes to the clipboard",
"paste-notes-from-clipboard": "Paste notes from the clipboard into active note",
"cut-notes-to-clipboard": "Cut selected notes to the clipboard",
"select-all-notes-in-parent": "Select all notes from the current note level",
"add-note-above-to-the-selection": "Add note above to the selection",
"add-note-below-to-selection": "Add note above to the selection",
"add-note-below-to-selection": "Add note below to the selection",
"duplicate-subtree": "Duplicate subtree",
"tabs-and-windows": "Tabs & Windows",
"open-new-tab": "Opens new tab",
"close-active-tab": "Closes active tab",
"reopen-last-tab": "Reopens the last closed tab",
"activate-next-tab": "Activates tab on the right",
"activate-previous-tab": "Activates tab on the left",
"open-new-tab": "Open new tab",
"close-active-tab": "Close active tab",
"reopen-last-tab": "Reopen the last closed tab",
"activate-next-tab": "Activate tab on the right",
"activate-previous-tab": "Activate tab on the left",
"open-new-window": "Open new empty window",
"toggle-tray": "Shows/hides the application from the system tray",
"first-tab": "Activates the first tab in the list",
"second-tab": "Activates the second tab in the list",
"third-tab": "Activates the third tab in the list",
"fourth-tab": "Activates the fourth tab in the list",
"fifth-tab": "Activates the fifth tab in the list",
"sixth-tab": "Activates the sixth tab in the list",
"seventh-tab": "Activates the seventh tab in the list",
"eight-tab": "Activates the eighth tab in the list",
"ninth-tab": "Activates the ninth tab in the list",
"last-tab": "Activates the last tab in the list",
"toggle-tray": "Show/hide the application from the system tray",
"first-tab": "Activate the first tab in the list",
"second-tab": "Activate the second tab in the list",
"third-tab": "Activate the third tab in the list",
"fourth-tab": "Activate the fourth tab in the list",
"fifth-tab": "Activate the fifth tab in the list",
"sixth-tab": "Activate the sixth tab in the list",
"seventh-tab": "Activate the seventh tab in the list",
"eight-tab": "Activate the eighth tab in the list",
"ninth-tab": "Activate the ninth tab in the list",
"last-tab": "Activate the last tab in the list",
"dialogs": "Dialogs",
"show-note-source": "Shows Note Source dialog",
"show-options": "Shows Options dialog",
"show-revisions": "Shows Note Revisions dialog",
"show-recent-changes": "Shows Recent Changes dialog",
"show-sql-console": "Shows SQL Console dialog",
"show-backend-log": "Shows Backend Log dialog",
"show-note-source": "Show \"Note Source\" dialog",
"show-options": "Open \"Options\" page",
"show-revisions": "Show \"Note Revisions\" dialog",
"show-recent-changes": "Show \"Recent Changes\" dialog",
"show-sql-console": "Open \"SQL Console\" page",
"show-backend-log": "Open \"Backend Log\" page",
"show-help": "Open the built-in User Guide",
"show-cheatsheet": "Show a modal with common keyboard operations",
"text-note-operations": "Text note operations",
"add-link-to-text": "Open dialog to add link to the text",
"follow-link-under-cursor": "Follow link within which the caret is placed",
@@ -76,10 +86,11 @@
"open-note-externally": "Open note as a file with default application",
"render-active-note": "Render (re-render) active note",
"run-active-note": "Run active JavaScript (frontend/backend) code note",
"toggle-note-hoisting": "Toggles note hoisting of active note",
"toggle-note-hoisting": "Toggle note hoisting of active note",
"unhoist": "Unhoist from anywhere",
"reload-frontend-app": "Reload frontend App",
"open-dev-tools": "Open dev tools",
"reload-frontend-app": "Reload frontend",
"open-dev-tools": "Open developer tools",
"find-in-text": "Toggle search panel",
"toggle-left-note-tree-panel": "Toggle left (note tree) panel",
"toggle-full-screen": "Toggle full screen",
"zoom-out": "Zoom Out",
@@ -88,11 +99,9 @@
"reset-zoom-level": "Reset zoom level",
"copy-without-formatting": "Copy selected text without formatting",
"force-save-revision": "Force creating / saving new note revision of the active note",
"show-help": "Shows the built-in User Guide",
"toggle-book-properties": "Toggle Book Properties",
"toggle-classic-editor-toolbar": "Toggle the Formatting tab for the editor with fixed toolbar",
"export-as-pdf": "Exports the current note as a PDF",
"show-cheatsheet": "Shows a modal with common keyboard operations",
"export-as-pdf": "Export the current note as a PDF",
"toggle-zen-mode": "Enables/disables the zen mode (minimal UI for more focused editing)"
},
"login": {

View File

@@ -118,6 +118,15 @@
<% if (themeCssUrl) { %>
<link href="<%= themeCssUrl %>" rel="stylesheet">
<% } %>
<% if (themeUseNextAsBase === "next") { %>
<link href="<%= assetPath %>/stylesheets/theme-next.css" rel="stylesheet">
<% } else if (themeUseNextAsBase === "next-dark") { %>
<link href="<%= assetPath %>/stylesheets/theme-next-dark.css" rel="stylesheet">
<% } else if (themeUseNextAsBase === "next-light") { %>
<link href="<%= assetPath %>/stylesheets/theme-next-light.css" rel="stylesheet">
<% } %>
<link href="<%= assetPath %>/stylesheets/style.css" rel="stylesheet">
<link href="<%= assetPath %>/stylesheets/print.css" rel="stylesheet" media="print">

View File

@@ -13,6 +13,7 @@
<link rel="shortcut icon" href="../favicon.ico">
<% } %>
<script src="<%= appPath %>/share.js" type="module"></script>
<link href="<%= assetPath %>/src/share.css" rel="stylesheet">
<% if (!note.isLabelTruthy("shareOmitDefaultCss")) { %>
<link href="<%= assetPath %>/stylesheets/share.css" rel="stylesheet">
<% } %>

View File

@@ -12,6 +12,7 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "@triliumnext/commons";
import BBlob from "./entities/bblob.js";
import BRecentNote from "./entities/brecent_note.js";
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
import type BNoteEmbedding from "./entities/bnote_embedding.js";
interface AttachmentOpts {
includeContentLength?: boolean;
@@ -32,6 +33,7 @@ export default class Becca {
attributeIndex!: Record<string, BAttribute[]>;
options!: Record<string, BOption>;
etapiTokens!: Record<string, BEtapiToken>;
noteEmbeddings!: Record<string, BNoteEmbedding>;
allNoteSetCache: NoteSet | null;
@@ -48,6 +50,7 @@ export default class Becca {
this.attributeIndex = {};
this.options = {};
this.etapiTokens = {};
this.noteEmbeddings = {};
this.dirtyNoteSetCache();

View File

@@ -9,9 +9,10 @@ import BBranch from "./entities/bbranch.js";
import BAttribute from "./entities/battribute.js";
import BOption from "./entities/boption.js";
import BEtapiToken from "./entities/betapi_token.js";
import BNoteEmbedding from "./entities/bnote_embedding.js";
import cls from "../services/cls.js";
import entityConstructor from "../becca/entity_constructor.js";
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons";
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow, NoteEmbeddingRow } from "@triliumnext/commons";
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
import ws from "../services/ws.js";
@@ -63,6 +64,18 @@ function load() {
for (const row of sql.getRows<EtapiTokenRow>(/*sql*/`SELECT etapiTokenId, name, tokenHash, utcDateCreated, utcDateModified FROM etapi_tokens WHERE isDeleted = 0`)) {
new BEtapiToken(row);
}
try {
for (const row of sql.getRows<NoteEmbeddingRow>(/*sql*/`SELECT embedId, noteId, providerId, modelId, dimension, embedding, version, dateCreated, dateModified, utcDateCreated, utcDateModified FROM note_embeddings`)) {
new BNoteEmbedding(row).init();
}
} catch (e: unknown) {
if (e && typeof e === "object" && "message" in e && typeof e.message === "string" && e.message.includes("no such table")) {
// Can be ignored.
} else {
throw e;
}
}
});
for (const noteId in becca.notes) {
@@ -85,7 +98,7 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_CHANGE_SYNCED], ({ entity
return;
}
if (["notes", "branches", "attributes", "etapi_tokens", "options"].includes(entityName)) {
if (["notes", "branches", "attributes", "etapi_tokens", "options", "note_embeddings"].includes(entityName)) {
const EntityClass = entityConstructor.getEntityFromEntityName(entityName);
const primaryKeyName = EntityClass.primaryKeyName;
@@ -143,6 +156,8 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_DELETED, eventService.ENT
attributeDeleted(entityId);
} else if (entityName === "etapi_tokens") {
etapiTokenDeleted(entityId);
} else if (entityName === "note_embeddings") {
noteEmbeddingDeleted(entityId);
}
});
@@ -278,6 +293,10 @@ function etapiTokenDeleted(etapiTokenId: string) {
delete becca.etapiTokens[etapiTokenId];
}
function noteEmbeddingDeleted(embedId: string) {
delete becca.noteEmbeddings[embedId];
}
eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
try {
becca.decryptProtectedNotes();

View File

@@ -32,6 +32,12 @@ class BNoteEmbedding extends AbstractBeccaEntity<BNoteEmbedding> {
}
}
init() {
if (this.embedId) {
this.becca.noteEmbeddings[this.embedId] = this;
}
}
updateFromRow(row: NoteEmbeddingRow): void {
this.embedId = row.embedId;
this.noteId = row.noteId;
@@ -44,6 +50,10 @@ class BNoteEmbedding extends AbstractBeccaEntity<BNoteEmbedding> {
this.dateModified = row.dateModified;
this.utcDateCreated = row.utcDateCreated;
this.utcDateModified = row.utcDateModified;
if (this.embedId) {
this.becca.noteEmbeddings[this.embedId] = this;
}
}
override beforeSaving() {

View File

@@ -4,10 +4,10 @@ import eu from "./etapi_utils.js";
import backupService from "../services/backup.js";
function register(router: Router) {
eu.route(router, "put", "/etapi/backup/:backupName", async (req, res, next) => {
await backupService.backupNow(req.params.backupName);
res.sendStatus(204);
eu.route(router, "put", "/etapi/backup/:backupName", (req, res, next) => {
backupService.backupNow(req.params.backupName)
.then(() => res.sendStatus(204))
.catch(() => res.sendStatus(500));
});
}

View File

@@ -6,7 +6,7 @@ import etapiTokenService from "../services/etapi_tokens.js";
import config from "../services/config.js";
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
import type { ValidatorMap } from "./etapi-interface.js";
import type { ApiRequestHandler } from "../routes/route_api.js";
import type { ApiRequestHandler, SyncRouteRequestHandler } from "../routes/route_api.js";
const GENERIC_CODE = "GENERIC";
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
@@ -73,11 +73,11 @@ function processRequest(req: Request, res: Response, routeHandler: ApiRequestHan
}
}
function route(router: Router, method: HttpMethod, path: string, routeHandler: ApiRequestHandler) {
function route(router: Router, method: HttpMethod, path: string, routeHandler: SyncRouteRequestHandler) {
router[method](path, checkEtapiAuth, (req: Request, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
}
function NOT_AUTHENTICATED_ROUTE(router: Router, method: HttpMethod, path: string, middleware: RequestHandler[], routeHandler: RequestHandler) {
function NOT_AUTHENTICATED_ROUTE(router: Router, method: HttpMethod, path: string, middleware: RequestHandler[], routeHandler: SyncRouteRequestHandler) {
router[method](path, ...middleware, (req: Request, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
}

View File

@@ -15,46 +15,46 @@ function isValidDate(date: string) {
}
function register(router: Router) {
eu.route(router, "get", "/etapi/inbox/:date", async (req, res, next) => {
eu.route(router, "get", "/etapi/inbox/:date", (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await specialNotesService.getInboxNote(date);
const note = specialNotesService.getInboxNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/days/:date", async (req, res, next) => {
eu.route(router, "get", "/etapi/calendar/days/:date", (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await dateNotesService.getDayNote(date);
const note = dateNotesService.getDayNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/week-first-day/:date", async (req, res, next) => {
eu.route(router, "get", "/etapi/calendar/week-first-day/:date", (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await dateNotesService.getWeekFirstDayNote(date);
const note = dateNotesService.getWeekFirstDayNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/weeks/:week", async (req, res, next) => {
eu.route(router, "get", "/etapi/calendar/weeks/:week", (req, res, next) => {
const { week } = req.params;
if (!/[0-9]{4}-W[0-9]{2}/.test(week)) {
throw getWeekInvalidError(week);
}
const note = await dateNotesService.getWeekNote(week);
const note = dateNotesService.getWeekNote(week);
if (!note) {
throw getWeekNotFoundError(week);
@@ -63,14 +63,14 @@ function register(router: Router) {
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/months/:month", async (req, res, next) => {
eu.route(router, "get", "/etapi/calendar/months/:month", (req, res, next) => {
const { month } = req.params;
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
throw getMonthInvalidError(month);
}
const note = await dateNotesService.getMonthNote(month);
const note = dateNotesService.getMonthNote(month);
res.json(mappers.mapNoteToPojo(note));
});

View File

@@ -7,7 +7,8 @@ import { initializeTranslations } from "./services/i18n.js";
async function startApplication() {
await initializeTranslations();
await import("./www.js");
const startTriliumServer = (await import("./www.js")).default;
await startTriliumServer();
}
startApplication();

View File

@@ -5,7 +5,6 @@ import options from "../../services/options.js";
// Import the index service for knowledge base management
import indexService from "../../services/llm/index_service.js";
import restChatService from "../../services/llm/rest_chat_service.js";
import chatService from '../../services/llm/chat_service.js';
import chatStorageService from '../../services/llm/chat_storage_service.js';
// Define basic interfaces
@@ -190,23 +189,26 @@ async function getSession(req: Request, res: Response) {
* tags: ["llm"]
*/
async function updateSession(req: Request, res: Response) {
// Get the chat using ChatService
// Get the chat using chatStorageService directly
const chatNoteId = req.params.chatNoteId;
const updates = req.body;
try {
// Get the chat
const session = await chatService.getOrCreateSession(chatNoteId);
const chat = await chatStorageService.getChat(chatNoteId);
if (!chat) {
throw new Error(`Chat with ID ${chatNoteId} not found`);
}
// Update title if provided
if (updates.title) {
await chatStorageService.updateChat(chatNoteId, session.messages, updates.title);
await chatStorageService.updateChat(chatNoteId, chat.messages, updates.title);
}
// Return the updated chat
return {
id: chatNoteId,
title: updates.title || session.title,
title: updates.title || chat.title,
updatedAt: new Date()
};
} catch (error) {
@@ -248,18 +250,18 @@ async function updateSession(req: Request, res: Response) {
* tags: ["llm"]
*/
async function listSessions(req: Request, res: Response) {
// Get all sessions using ChatService
// Get all sessions using chatStorageService directly
try {
const sessions = await chatService.getAllSessions();
const chats = await chatStorageService.getAllChats();
// Format the response
return {
sessions: sessions.map(session => ({
id: session.id,
title: session.title,
createdAt: new Date(), // Since we don't have this in chat sessions
lastActive: new Date(), // Since we don't have this in chat sessions
messageCount: session.messages.length
sessions: chats.map(chat => ({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt || new Date(),
lastActive: chat.updatedAt || new Date(),
messageCount: chat.messages.length
}))
};
} catch (error) {
@@ -811,17 +813,38 @@ async function streamMessage(req: Request, res: Response) {
const { content, useAdvancedContext, showThinking, mentions } = req.body;
if (!content || typeof content !== 'string' || content.trim().length === 0) {
throw new Error('Content cannot be empty');
return res.status(400).json({
success: false,
error: 'Content cannot be empty'
});
}
// IMPORTANT: Immediately send a success response to the initial POST request
// The client is waiting for this to confirm streaming has been initiated
res.status(200).json({
success: true,
message: 'Streaming initiated successfully'
});
log.info(`Sent immediate success response for streaming setup`);
// Create a new response object for streaming through WebSocket only
// We won't use HTTP streaming since we've already sent the HTTP response
// Check if session exists
const session = restChatService.getSessions().get(chatNoteId);
if (!session) {
throw new Error('Chat not found');
// Get or create chat directly from storage (simplified approach)
let chat = await chatStorageService.getChat(chatNoteId);
if (!chat) {
// Create a new chat if it doesn't exist
chat = await chatStorageService.createChat('New Chat');
log.info(`Created new chat with ID: ${chat.id} for stream request`);
}
// Update last active timestamp
session.lastActive = new Date();
// Add the user message to the chat immediately
chat.messages.push({
role: 'user',
content
});
// Save the chat to ensure the user message is recorded
await chatStorageService.updateChat(chat.id, chat.messages, chat.title);
// Process mentions if provided
let enhancedContent = content;
@@ -830,7 +853,6 @@ async function streamMessage(req: Request, res: Response) {
// Import note service to get note content
const becca = (await import('../../becca/becca.js')).default;
const mentionContexts: string[] = [];
for (const mention of mentions) {
@@ -857,102 +879,94 @@ async function streamMessage(req: Request, res: Response) {
}
}
// Add user message to the session (with enhanced content for processing)
session.messages.push({
role: 'user',
content: enhancedContent,
timestamp: new Date()
});
// Create request parameters for the pipeline
const requestParams = {
chatNoteId: chatNoteId,
content: enhancedContent,
useAdvancedContext: useAdvancedContext === true,
showThinking: showThinking === true,
stream: true // Always stream for this endpoint
};
// Create a fake request/response pair to pass to the handler
const fakeReq = {
...req,
method: 'GET', // Set to GET to indicate streaming
query: {
stream: 'true', // Set stream param - don't use format: 'stream' to avoid confusion
useAdvancedContext: String(useAdvancedContext === true),
showThinking: String(showThinking === true)
},
params: {
chatNoteId: chatNoteId
},
// Make sure the enhanced content is available to the handler
body: {
content: enhancedContent,
useAdvancedContext: useAdvancedContext === true,
showThinking: showThinking === true
}
} as unknown as Request;
// Log to verify correct parameters
log.info(`WebSocket stream settings - useAdvancedContext=${useAdvancedContext === true}, in query=${fakeReq.query.useAdvancedContext}, in body=${fakeReq.body.useAdvancedContext}`);
// Extra safety to ensure the parameters are passed correctly
if (useAdvancedContext === true) {
log.info(`Enhanced context IS enabled for this request`);
} else {
log.info(`Enhanced context is NOT enabled for this request`);
}
// Process the request in the background
Promise.resolve().then(async () => {
try {
await restChatService.handleSendMessage(fakeReq, res);
} catch (error) {
log.error(`Background message processing error: ${error}`);
// Import the WebSocket service
const wsService = (await import('../../services/ws.js')).default;
// Define LLMStreamMessage interface
interface LLMStreamMessage {
type: 'llm-stream';
chatNoteId: string;
content?: string;
thinking?: string;
toolExecution?: any;
done?: boolean;
error?: string;
raw?: unknown;
}
// Send error to client via WebSocket
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
error: `Error processing message: ${error}`,
done: true
} as LLMStreamMessage);
}
});
// Import the WebSocket service
// Import the WebSocket service to send immediate feedback
const wsService = (await import('../../services/ws.js')).default;
// Let the client know streaming has started via WebSocket (helps client confirm connection is working)
// Let the client know streaming has started
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
thinking: 'Initializing streaming LLM response...'
thinking: showThinking ? 'Initializing streaming LLM response...' : undefined
});
// Let the client know streaming has started via HTTP response
return {
success: true,
message: 'Streaming started',
chatNoteId: chatNoteId
};
// Instead of trying to reimplement the streaming logic ourselves,
// delegate to restChatService but set up the correct protocol:
// 1. We've already sent a success response to the initial POST
// 2. Now we'll have restChatService process the actual streaming through WebSocket
try {
// Import the WebSocket service for sending messages
const wsService = (await import('../../services/ws.js')).default;
// Create a simple pass-through response object that won't write to the HTTP response
// but will allow restChatService to send WebSocket messages
const dummyResponse = {
writableEnded: false,
// Implement methods that would normally be used by restChatService
write: (_chunk: string) => {
// Silent no-op - we're only using WebSocket
return true;
},
end: (_chunk?: string) => {
// Log when streaming is complete via WebSocket
log.info(`[${chatNoteId}] Completed HTTP response handling during WebSocket streaming`);
return dummyResponse;
},
setHeader: (name: string, _value: string) => {
// Only log for content-type to reduce noise
if (name.toLowerCase() === 'content-type') {
log.info(`[${chatNoteId}] Setting up streaming for WebSocket only`);
}
return dummyResponse;
}
};
// Process the streaming now through WebSocket only
try {
log.info(`[${chatNoteId}] Processing LLM streaming through WebSocket after successful initiation at ${new Date().toISOString()}`);
// Call restChatService with our enhanced request and dummy response
// The important part is setting method to GET to indicate streaming mode
await restChatService.handleSendMessage({
...req,
method: 'GET', // Indicate streaming mode
query: {
...req.query,
stream: 'true' // Add the required stream parameter
},
body: {
content: enhancedContent,
useAdvancedContext: useAdvancedContext === true,
showThinking: showThinking === true
},
params: { chatNoteId }
} as unknown as Request, dummyResponse as unknown as Response);
log.info(`[${chatNoteId}] WebSocket streaming completed at ${new Date().toISOString()}`);
} catch (streamError) {
log.error(`[${chatNoteId}] Error during WebSocket streaming: ${streamError}`);
// Send error message through WebSocket
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
error: `Error during streaming: ${streamError}`,
done: true
});
}
} catch (error) {
log.error(`Error during streaming: ${error}`);
// Send error to client via WebSocket
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
error: `Error processing message: ${error}`,
done: true
});
}
} catch (error: any) {
log.error(`Error starting message stream: ${error.message}`);
throw error;
log.error(`Error starting message stream, can't communicate via WebSocket: ${error.message}`);
}
}

View File

@@ -81,13 +81,13 @@ async function listModels(req: Request, res: Response) {
// Filter and categorize models
const allModels = response.data || [];
// Separate models into chat models and embedding models
// Include all models as chat models, without filtering by specific model names
// This allows models from providers like OpenRouter to be displayed
const chatModels = allModels
.filter((model) =>
// Include GPT models for chat
model.id.includes('gpt') ||
// Include Claude models via Azure OpenAI
model.id.includes('claude')
.filter((model) =>
// Exclude models that are explicitly for embeddings
!model.id.includes('embedding') &&
!model.id.includes('embed')
)
.map((model) => ({
id: model.id,

View File

@@ -57,6 +57,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"headingStyle",
"autoCollapseNoteTree",
"autoReadonlySizeText",
"customDateTimeFormat",
"autoReadonlySizeCode",
"overrideThemeFonts",
"dailyBackupEnabled",

View File

@@ -0,0 +1,73 @@
import supertest from "supertest";
import options from "./options";
import cls from "./cls";
import { Application } from "express";
import config from "./config";
import { refreshAuth } from "./auth";
let app: Application;
describe("Auth", () => {
beforeAll(async () => {
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
});
describe("Auth", () => {
beforeAll(() => {
config.General.noAuthentication = false;
refreshAuth();
});
it("goes to login and asks for TOTP if enabled", async () => {
cls.init(() => {
options.setOption("mfaEnabled", "true");
options.setOption("mfaMethod", "totp");
options.setOption("totpVerificationHash", "hi");
});
const response = await supertest(app)
.get("/")
.redirects(1)
.expect(200);
expect(response.text).toContain(`id="totpToken"`);
});
it("goes to login and doesn't ask for TOTP is disabled", async () => {
cls.init(() => {
options.setOption("mfaEnabled", "false");
});
const response = await supertest(app)
.get("/")
.redirects(1)
.expect(200)
expect(response.text).not.toContain(`id="totpToken"`);
});
});
describe("No auth", () => {
beforeAll(() => {
config.General.noAuthentication = true;
refreshAuth();
});
it("doesn't ask for authentication when disabled, even if TOTP is enabled", async () => {
cls.init(() => {
options.setOption("mfaEnabled", "true");
options.setOption("mfaMethod", "totp");
options.setOption("totpVerificationHash", "hi");
});
await supertest(app)
.get("/")
.expect(200);
});
it("doesn't ask for authentication when disabled, with TOTP disabled", async () => {
cls.init(() => {
options.setOption("mfaEnabled", "false");
});
await supertest(app)
.get("/")
.expect(200);
});
});
}, 60_000);

View File

@@ -11,7 +11,8 @@ import options from "./options.js";
import attributes from "./attributes.js";
import type { NextFunction, Request, Response } from "express";
const noAuthentication = config.General && config.General.noAuthentication === true;
let noAuthentication = false;
refreshAuth();
function checkAuth(req: Request, res: Response, next: NextFunction) {
if (!sqlInit.isDbInitialized()) {
@@ -22,7 +23,7 @@ function checkAuth(req: Request, res: Response, next: NextFunction) {
const currentSsoStatus = openID.isOpenIDEnabled();
const lastAuthState = req.session.lastAuthState || { totpEnabled: false, ssoEnabled: false };
if (isElectron) {
if (isElectron || noAuthentication) {
next();
return;
} else if (currentTotpStatus !== lastAuthState.totpEnabled || currentSsoStatus !== lastAuthState.ssoEnabled) {
@@ -58,12 +59,21 @@ function checkAuth(req: Request, res: Response, next: NextFunction) {
}
}
/**
* Rechecks whether authentication is needed or not by re-reading the config.
* The value is cached to avoid reading at every request.
*
* Generally this method should only be called during tests.
*/
export function refreshAuth() {
noAuthentication = (config.General && config.General.noAuthentication === true);
}
// for electron things which need network stuff
// currently, we're doing that for file upload because handling form data seems to be difficult
function checkApiAuthOrElectron(req: Request, res: Response, next: NextFunction) {
if (!req.session.loggedIn && !isElectron && !noAuthentication) {
console.warn(`Missing session with ID '${req.sessionID}'.`);
reject(req, res, "Logged in session not found");
} else {
next();
@@ -72,6 +82,7 @@ function checkApiAuthOrElectron(req: Request, res: Response, next: NextFunction)
function checkApiAuth(req: Request, res: Response, next: NextFunction) {
if (!req.session.loggedIn && !noAuthentication) {
console.warn(`Missing session with ID '${req.sessionID}'.`);
reject(req, res, "Logged in session not found");
} else {
next();

View File

@@ -799,6 +799,7 @@ class ConsistencyChecks {
this.runEntityChangeChecks("attributes", "attributeId");
this.runEntityChangeChecks("etapi_tokens", "etapiTokenId");
this.runEntityChangeChecks("options", "name");
this.runEntityChangeChecks("note_embeddings", "embedId");
}
findWronglyNamedAttributes() {

View File

@@ -266,7 +266,7 @@ function getMonthNote(dateStr: string, _rootNote: BNote | null = null): BNote {
return monthNote;
}
let monthParentNote;
let monthParentNote: BNote | null;
if (rootNote.hasLabel("enableQuarterNote")) {
monthParentNote = getQuarterNote(getQuarterNumberStr(dayjs(dateStr)), rootNote);
@@ -296,7 +296,7 @@ function getMonthNote(dateStr: string, _rootNote: BNote | null = null): BNote {
function getWeekStartDate(date: Dayjs): Dayjs {
const day = date.day();
let diff;
let diff: number;
if (optionService.getOption("firstDayOfWeek") === "0") { // Sunday
diff = date.date() - day + (day === 0 ? -6 : 1); // adjust when day is sunday
@@ -456,7 +456,7 @@ function getDayNote(dateStr: string, _rootNote: BNote | null = null): BNote {
return dateNote;
}
let dateParentNote;
let dateParentNote: BNote | null;
if (rootNote.hasLabel("enableWeekNote")) {
dateParentNote = getWeekNote(getWeekNumberStr(dayjs(dateStr)), rootNote);

View File

@@ -1,5 +1,5 @@
import sanitizeHtml from "sanitize-html";
import sanitizeUrl from "@braintree/sanitize-url";
import { sanitizeUrl } from "@braintree/sanitize-url";
import optionService from "./options.js";
// Be consistent with `ALLOWED_PROTOCOLS` in `src\public\app\services\link.js`
@@ -190,6 +190,6 @@ function sanitize(dirtyHtml: string) {
export default {
sanitize,
sanitizeUrl: (url: string) => {
return sanitizeUrl.sanitizeUrl(url).trim();
return sanitizeUrl(url).trim();
}
};

View File

@@ -19,12 +19,14 @@ function getDefaultKeyboardActions() {
actionName: "backInNoteHistory",
// Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
defaultShortcuts: isMac ? ["CommandOrControl+Left"] : ["Alt+Left"],
description: t("keyboard_actions.back-in-note-history"),
scope: "window"
},
{
actionName: "forwardInNoteHistory",
// Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
defaultShortcuts: isMac ? ["CommandOrControl+Right"] : ["Alt+Right"],
description: t("keyboard_actions.forward-in-note-history"),
scope: "window"
},
{
@@ -36,11 +38,13 @@ function getDefaultKeyboardActions() {
{
actionName: "scrollToActiveNote",
defaultShortcuts: ["CommandOrControl+."],
description: t("keyboard_actions.scroll-to-active-note"),
scope: "window"
},
{
actionName: "quickSearch",
defaultShortcuts: ["CommandOrControl+S"],
description: t("keyboard_actions.quick-search"),
scope: "window"
},
{
@@ -80,11 +84,13 @@ function getDefaultKeyboardActions() {
{
actionName: "createNoteAfter",
defaultShortcuts: ["CommandOrControl+O"],
description: t("keyboard_actions.create-note-after"),
scope: "window"
},
{
actionName: "createNoteInto",
defaultShortcuts: ["CommandOrControl+P"],
description: t("keyboard_actions.create-note-into"),
scope: "window"
},
{
@@ -138,11 +144,13 @@ function getDefaultKeyboardActions() {
{
actionName: "cloneNotesTo",
defaultShortcuts: ["CommandOrControl+Shift+C"],
description: t("keyboard_actions.clone-notes-to"),
scope: "window"
},
{
actionName: "moveNotesTo",
defaultShortcuts: ["CommandOrControl+Shift+X"],
description: t("keyboard_actions.move-notes-to"),
scope: "window"
},
@@ -566,6 +574,7 @@ function getDefaultKeyboardActions() {
{
actionName: "findInText",
defaultShortcuts: isElectron ? ["CommandOrControl+F"] : [],
description: t("keyboard_actions.find-in-text"),
scope: "window"
},
{

View File

@@ -18,6 +18,19 @@ import type {
} from './interfaces/ai_service_interfaces.js';
import type { NoteSearchResult } from './interfaces/context_interfaces.js';
// Import new configuration system
import {
getProviderPrecedence,
getPreferredProvider,
getEmbeddingProviderPrecedence,
parseModelIdentifier,
isAIEnabled,
getDefaultModelForProvider,
clearConfigurationCache,
validateConfiguration
} from './config/configuration_helpers.js';
import type { ProviderType } from './interfaces/configuration_interfaces.js';
/**
* Interface representing relevant note context
*/
@@ -36,7 +49,7 @@ export class AIServiceManager implements IAIServiceManager {
ollama: new OllamaService()
};
private providerOrder: ServiceProviders[] = ['openai', 'anthropic', 'ollama']; // Default order
private providerOrder: ServiceProviders[] = []; // Will be populated from configuration
private initialized = false;
constructor() {
@@ -71,7 +84,24 @@ export class AIServiceManager implements IAIServiceManager {
}
/**
* Update the provider precedence order from saved options
* Update the provider precedence order using the new configuration system
*/
async updateProviderOrderAsync(): Promise<void> {
try {
const providers = await getProviderPrecedence();
this.providerOrder = providers as ServiceProviders[];
this.initialized = true;
log.info(`Updated provider order: ${providers.join(', ')}`);
} catch (error) {
log.error(`Failed to get provider precedence: ${error}`);
// Keep empty order, will be handled gracefully by other methods
this.providerOrder = [];
this.initialized = true;
}
}
/**
* Update the provider precedence order (legacy sync version)
* Returns true if successful, false if options not available yet
*/
updateProviderOrder(): boolean {
@@ -79,146 +109,48 @@ export class AIServiceManager implements IAIServiceManager {
return true;
}
try {
// Default precedence: openai, anthropic, ollama
const defaultOrder: ServiceProviders[] = ['openai', 'anthropic', 'ollama'];
// Use async version but don't wait
this.updateProviderOrderAsync().catch(error => {
log.error(`Error in async provider order update: ${error}`);
});
// Get custom order from options
const customOrder = options.getOption('aiProviderPrecedence');
if (customOrder) {
try {
// Try to parse as JSON first
let parsed;
// Handle both array in JSON format and simple string format
if (customOrder.startsWith('[') && customOrder.endsWith(']')) {
parsed = JSON.parse(customOrder);
} else if (typeof customOrder === 'string') {
// If it's a string with commas, split it
if (customOrder.includes(',')) {
parsed = customOrder.split(',').map(p => p.trim());
} else {
// If it's a simple string (like "ollama"), convert to single-item array
parsed = [customOrder];
}
} else {
// Fallback to default
parsed = defaultOrder;
}
// Validate that all providers are valid
if (Array.isArray(parsed) &&
parsed.every(p => Object.keys(this.services).includes(p))) {
this.providerOrder = parsed as ServiceProviders[];
} else {
log.info('Invalid AI provider precedence format, using defaults');
this.providerOrder = defaultOrder;
}
} catch (e) {
log.error(`Failed to parse AI provider precedence: ${e}`);
this.providerOrder = defaultOrder;
}
} else {
this.providerOrder = defaultOrder;
}
this.initialized = true;
// Remove the validateEmbeddingProviders call since we now do validation on the client
// this.validateEmbeddingProviders();
return true;
} catch (error) {
// If options table doesn't exist yet, use defaults
// This happens during initial database creation
this.providerOrder = ['openai', 'anthropic', 'ollama'];
return false;
}
return true;
}
/**
* Validate embedding providers configuration
* - Check if embedding default provider is in provider precedence list
* - Check if all providers in precedence list and default provider are enabled
*
* @returns A warning message if there are issues, or null if everything is fine
* Validate AI configuration using the new configuration system
*/
async validateEmbeddingProviders(): Promise<string | null> {
async validateConfiguration(): Promise<string | null> {
try {
// Check if AI is enabled, if not, skip validation
const aiEnabled = await options.getOptionBool('aiEnabled');
if (!aiEnabled) {
return null;
const result = await validateConfiguration();
if (!result.isValid) {
let message = 'There are issues with your AI configuration:';
for (const error of result.errors) {
message += `\n• ${error}`;
}
if (result.warnings.length > 0) {
message += '\n\nWarnings:';
for (const warning of result.warnings) {
message += `\n• ${warning}`;
}
}
message += '\n\nPlease check your AI settings.';
return message;
}
// Get precedence list from options
let precedenceList: string[] = ['openai']; // Default to openai if not set
const precedenceOption = await options.getOption('aiProviderPrecedence');
if (precedenceOption) {
try {
if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) {
precedenceList = JSON.parse(precedenceOption);
} else if (typeof precedenceOption === 'string') {
if (precedenceOption.includes(',')) {
precedenceList = precedenceOption.split(',').map(p => p.trim());
} else {
precedenceList = [precedenceOption];
}
}
} catch (e) {
log.error(`Error parsing precedence list: ${e}`);
if (result.warnings.length > 0) {
let message = 'AI configuration warnings:';
for (const warning of result.warnings) {
message += `\n• ${warning}`;
}
}
// Check for configuration issues with providers in the precedence list
const configIssues: string[] = [];
// Check each provider in the precedence list for proper configuration
for (const provider of precedenceList) {
if (provider === 'openai') {
// Check OpenAI configuration
const apiKey = await options.getOption('openaiApiKey');
if (!apiKey) {
configIssues.push(`OpenAI API key is missing`);
}
} else if (provider === 'anthropic') {
// Check Anthropic configuration
const apiKey = await options.getOption('anthropicApiKey');
if (!apiKey) {
configIssues.push(`Anthropic API key is missing`);
}
} else if (provider === 'ollama') {
// Check Ollama configuration
const baseUrl = await options.getOption('ollamaBaseUrl');
if (!baseUrl) {
configIssues.push(`Ollama Base URL is missing`);
}
}
// Add checks for other providers as needed
}
// Return warning message if there are configuration issues
if (configIssues.length > 0) {
let message = 'There are issues with your AI provider configuration:';
for (const issue of configIssues) {
message += `\n• ${issue}`;
}
message += '\n\nPlease check your AI settings.';
// Log warning to console
log.error('AI Provider Configuration Warning: ' + message);
return message;
log.info(message);
}
return null;
} catch (error) {
log.error(`Error validating embedding providers: ${error}`);
return null;
log.error(`Error validating AI configuration: ${error}`);
return `Configuration validation failed: ${error}`;
}
}
@@ -279,18 +211,20 @@ export class AIServiceManager implements IAIServiceManager {
// If a specific provider is requested and available, use it
if (options.model && options.model.includes(':')) {
const [providerName, modelName] = options.model.split(':');
// Use the new configuration system to parse model identifier
const modelIdentifier = parseModelIdentifier(options.model);
if (availableProviders.includes(providerName as ServiceProviders)) {
if (modelIdentifier.provider && availableProviders.includes(modelIdentifier.provider as ServiceProviders)) {
try {
const modifiedOptions = { ...options, model: modelName };
log.info(`[AIServiceManager] Using provider ${providerName} from model prefix with modifiedOptions.stream: ${modifiedOptions.stream}`);
return await this.services[providerName as ServiceProviders].generateChatCompletion(messages, modifiedOptions);
const modifiedOptions = { ...options, model: modelIdentifier.modelId };
log.info(`[AIServiceManager] Using provider ${modelIdentifier.provider} from model prefix with modifiedOptions.stream: ${modifiedOptions.stream}`);
return await this.services[modelIdentifier.provider as ServiceProviders].generateChatCompletion(messages, modifiedOptions);
} catch (error) {
log.error(`Error with specified provider ${providerName}: ${error}`);
log.error(`Error with specified provider ${modelIdentifier.provider}: ${error}`);
// If the specified provider fails, continue with the fallback providers
}
}
// If not a provider prefix, treat the entire string as a model name and continue with normal provider selection
}
// Try each provider in order until one succeeds
@@ -390,39 +324,33 @@ export class AIServiceManager implements IAIServiceManager {
}
/**
* Get whether AI features are enabled from options
* Get whether AI features are enabled using the new configuration system
*/
async getAIEnabledAsync(): Promise<boolean> {
return isAIEnabled();
}
/**
* Get whether AI features are enabled (sync version for compatibility)
*/
getAIEnabled(): boolean {
// For synchronous compatibility, use the old method
// In a full refactor, this should be async
return options.getOptionBool('aiEnabled');
}
/**
* Set up embeddings provider for AI features
* Set up embeddings provider using the new configuration system
*/
async setupEmbeddingsProvider(): Promise<void> {
try {
if (!this.getAIEnabled()) {
const aiEnabled = await isAIEnabled();
if (!aiEnabled) {
log.info('AI features are disabled');
return;
}
// Get provider precedence list
const precedenceOption = await options.getOption('embeddingProviderPrecedence');
let precedenceList: string[] = [];
if (precedenceOption) {
if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) {
precedenceList = JSON.parse(precedenceOption);
} else if (typeof precedenceOption === 'string') {
if (precedenceOption.includes(',')) {
precedenceList = precedenceOption.split(',').map(p => p.trim());
} else {
precedenceList = [precedenceOption];
}
}
}
// Check if we have enabled providers
// Use the new configuration system - no string parsing!
const enabledProviders = await getEnabledEmbeddingProviders();
if (enabledProviders.length === 0) {
@@ -439,20 +367,23 @@ export class AIServiceManager implements IAIServiceManager {
}
/**
* Initialize the AI Service
* Initialize the AI Service using the new configuration system
*/
async initialize(): Promise<void> {
try {
log.info("Initializing AI service...");
// Check if AI is enabled in options
const isAIEnabled = this.getAIEnabled();
// Check if AI is enabled using the new helper
const aiEnabled = await isAIEnabled();
if (!isAIEnabled) {
if (!aiEnabled) {
log.info("AI features are disabled in options");
return;
}
// Update provider order from configuration
await this.updateProviderOrderAsync();
// Set up embeddings provider if AI is enabled
await this.setupEmbeddingsProvider();
@@ -586,7 +517,25 @@ export class AIServiceManager implements IAIServiceManager {
}
/**
* Get the preferred provider based on configuration
* Get the preferred provider based on configuration using the new system
*/
async getPreferredProviderAsync(): Promise<string> {
try {
const preferredProvider = await getPreferredProvider();
if (preferredProvider === null) {
// No providers configured, fallback to first available
log.info('No providers configured in precedence, using first available provider');
return this.providerOrder[0];
}
return preferredProvider;
} catch (error) {
log.error(`Error getting preferred provider: ${error}`);
return this.providerOrder[0];
}
}
/**
* Get the preferred provider based on configuration (sync version for compatibility)
*/
getPreferredProvider(): string {
this.ensureInitialized();
@@ -669,7 +618,7 @@ export default {
},
// Add validateEmbeddingProviders method
async validateEmbeddingProviders(): Promise<string | null> {
return getInstance().validateEmbeddingProviders();
return getInstance().validateConfiguration();
},
// Context and index related methods
getContextExtractor() {

View File

@@ -3,7 +3,6 @@
*/
import log from "../../../log.js";
import type { Message } from "../../ai_interface.js";
import SessionsStore from "../sessions_store.js";
/**
* Handles the execution of LLM tools
@@ -101,11 +100,6 @@ export class ToolHandler {
: JSON.stringify(result).substring(0, 100) + '...';
log.info(`Tool result: ${resultPreview}`);
// Record tool execution in session if chatNoteId is provided
if (chatNoteId) {
SessionsStore.recordToolExecution(chatNoteId, toolCall, typeof result === 'string' ? result : JSON.stringify(result));
}
// Format result as a proper message
return {
role: 'tool',
@@ -116,11 +110,6 @@ export class ToolHandler {
} catch (error: any) {
log.error(`Error executing tool ${toolCall.function.name}: ${error.message}`);
// Record error in session if chatNoteId is provided
if (chatNoteId) {
SessionsStore.recordToolExecution(chatNoteId, toolCall, '', error.message);
}
// Return error as tool result
return {
role: 'tool',

View File

@@ -2,7 +2,6 @@
* Chat module export
*/
import restChatService from './rest_chat_service.js';
import sessionsStore from './sessions_store.js';
import { ContextHandler } from './handlers/context_handler.js';
import { ToolHandler } from './handlers/tool_handler.js';
import { StreamHandler } from './handlers/stream_handler.js';
@@ -13,7 +12,6 @@ import type { LLMStreamMessage } from '../interfaces/chat_ws_messages.js';
// Export components
export {
restChatService as default,
sessionsStore,
ContextHandler,
ToolHandler,
StreamHandler,

View File

@@ -1,5 +1,6 @@
/**
* Service to handle chat API interactions
* Simplified service to handle chat API interactions
* Works directly with ChatStorageService - no complex session management
*/
import log from "../../log.js";
import type { Request, Response } from "express";
@@ -8,21 +9,16 @@ import { AIServiceManager } from "../ai_service_manager.js";
import { ChatPipeline } from "../pipeline/chat_pipeline.js";
import type { ChatPipelineInput } from "../pipeline/interfaces.js";
import options from "../../options.js";
import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
// Import our refactored modules
import { ContextHandler } from "./handlers/context_handler.js";
import { ToolHandler } from "./handlers/tool_handler.js";
import { StreamHandler } from "./handlers/stream_handler.js";
import SessionsStore from "./sessions_store.js";
import * as MessageFormatter from "./utils/message_formatter.js";
import type { NoteSource } from "../interfaces/chat_session.js";
import type { LLMStreamMessage } from "../interfaces/chat_ws_messages.js";
import type { ChatMessage } from '../interfaces/chat_session.js';
import type { ChatSession } from '../interfaces/chat_session.js';
import chatStorageService from '../chat_storage_service.js';
import {
isAIEnabled,
getFirstValidModelConfig,
} from '../config/configuration_helpers.js';
/**
* Service to handle chat API interactions
* Simplified service to handle chat API interactions
*/
class RestChatService {
/**
@@ -41,35 +37,15 @@ class RestChatService {
* Check if AI services are available
*/
safelyUseAIManager(): boolean {
// Only use AI manager if database is initialized
if (!this.isDatabaseInitialized()) {
log.info("AI check failed: Database is not initialized");
return false;
}
// Try to access the manager - will create instance only if needed
try {
// Create local instance to avoid circular references
const aiManager = new AIServiceManager();
if (!aiManager) {
log.info("AI check failed: AI manager module is not available");
return false;
}
const isAvailable = aiManager.isAnyServiceAvailable();
log.info(`AI service availability check result: ${isAvailable}`);
if (isAvailable) {
// Additional diagnostics
try {
const providers = aiManager.getAvailableProviders();
log.info(`Available AI providers: ${providers.join(', ')}`);
} catch (err) {
log.info(`Could not get available providers: ${err}`);
}
}
return isAvailable;
} catch (error) {
log.error(`Error accessing AI service manager: ${error}`);
@@ -79,505 +55,330 @@ class RestChatService {
/**
* Handle a message sent to an LLM and get a response
* Simplified to work directly with chat storage
*/
async handleSendMessage(req: Request, res: Response) {
log.info("=== Starting handleSendMessage ===");
log.info("=== Starting simplified handleSendMessage ===");
try {
// Extract parameters differently based on the request method
// Extract parameters
let content, useAdvancedContext, showThinking, chatNoteId;
if (req.method === 'POST') {
// For POST requests, get content from the request body
const requestBody = req.body || {};
content = requestBody.content;
useAdvancedContext = requestBody.useAdvancedContext || false;
showThinking = requestBody.showThinking || false;
// Add logging for POST requests
log.info(`LLM POST message: chatNoteId=${req.params.chatNoteId}, useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, contentLength=${content ? content.length : 0}`);
log.info(`LLM POST message: chatNoteId=${req.params.chatNoteId}, contentLength=${content ? content.length : 0}`);
} else if (req.method === 'GET') {
// For GET (streaming) requests, get parameters from query params and body
// For streaming requests, we need the content from the body
useAdvancedContext = req.query.useAdvancedContext === 'true' || (req.body && req.body.useAdvancedContext === true);
showThinking = req.query.showThinking === 'true' || (req.body && req.body.showThinking === true);
content = req.body && req.body.content ? req.body.content : '';
// Add detailed logging for GET requests
log.info(`LLM GET stream: chatNoteId=${req.params.chatNoteId}, useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}`);
log.info(`Parameters from query: useAdvancedContext=${req.query.useAdvancedContext}, showThinking=${req.query.showThinking}`);
log.info(`Parameters from body: useAdvancedContext=${req.body?.useAdvancedContext}, showThinking=${req.body?.showThinking}, content=${content ? `${content.substring(0, 20)}...` : 'none'}`);
log.info(`LLM GET stream: chatNoteId=${req.params.chatNoteId}`);
}
// Get chatNoteId from URL params
chatNoteId = req.params.chatNoteId;
// For GET requests, ensure we have the stream parameter
// Validate inputs
if (req.method === 'GET' && req.query.stream !== 'true') {
throw new Error('Stream parameter must be set to true for GET/streaming requests');
}
// For POST requests, validate the content
if (req.method === 'POST' && (!content || typeof content !== 'string' || content.trim().length === 0)) {
throw new Error('Content cannot be empty');
}
// Get or create session from Chat Note
let session = await this.getOrCreateSessionFromChatNote(chatNoteId, req.method === 'POST');
// Check if AI is enabled
const aiEnabled = await options.getOptionBool('aiEnabled');
if (!aiEnabled) {
return { error: "AI features are disabled. Please enable them in the settings." };
}
// If no session found and we're not allowed to create one (GET request)
if (!session && req.method === 'GET') {
if (!this.safelyUseAIManager()) {
return { error: "AI services are currently unavailable. Please check your configuration." };
}
// Load or create chat directly from storage
let chat = await chatStorageService.getChat(chatNoteId);
if (!chat && req.method === 'GET') {
throw new Error('Chat Note not found, cannot create session for streaming');
}
// For POST requests, if no Chat Note exists, create a new one
if (!session && req.method === 'POST') {
log.info(`No Chat Note found for ${chatNoteId}, creating a new Chat Note and session`);
// Create a new Chat Note via the storage service
//const chatStorageService = (await import('../../llm/chat_storage_service.js')).default;
//const newChat = await chatStorageService.createChat('New Chat');
// Use the new Chat Note's ID for the session
session = SessionsStore.createSession({
//title: newChat.title,
chatNoteId: chatNoteId
});
// Update the session ID to match the Chat Note ID
session.id = chatNoteId;
log.info(`Created new Chat Note and session with ID: ${session.id}`);
// Update the parameter to use the new ID
chatNoteId = session.id;
if (!chat && req.method === 'POST') {
log.info(`Creating new chat note with ID: ${chatNoteId}`);
chat = await chatStorageService.createChat('New Chat');
// Update the chat ID to match the requested ID if possible
// In practice, we'll use the generated ID
chatNoteId = chat.id;
}
// At this point, session should never be null
// TypeScript doesn't know this, so we'll add a check
if (!session) {
// This should never happen due to our logic above
throw new Error('Failed to create or retrieve session');
if (!chat) {
throw new Error('Failed to create or retrieve chat');
}
// Update session last active timestamp
SessionsStore.touchSession(session.id);
// For POST requests, store the user message
if (req.method === 'POST' && content && session) {
// Add message to session
session.messages.push({
// For POST requests, add the user message to the chat immediately
// This ensures user messages are always saved
if (req.method === 'POST' && content) {
chat.messages.push({
role: 'user',
content,
timestamp: new Date()
content
});
// Log a preview of the message
log.info(`Processing LLM message: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"`);
}
// Check if AI services are enabled before proceeding
const aiEnabled = await options.getOptionBool('aiEnabled');
log.info(`AI enabled setting: ${aiEnabled}`);
if (!aiEnabled) {
log.info("AI services are disabled by configuration");
return {
error: "AI features are disabled. Please enable them in the settings."
};
}
// Check if AI services are available
log.info("Checking if AI services are available...");
if (!this.safelyUseAIManager()) {
log.info("AI services are not available - checking for specific issues");
try {
// Create a direct instance to avoid circular references
const aiManager = new AIServiceManager();
if (!aiManager) {
log.error("AI service manager is not initialized");
return {
error: "AI service is not properly initialized. Please check your configuration."
};
}
const availableProviders = aiManager.getAvailableProviders();
if (availableProviders.length === 0) {
log.error("No AI providers are available");
return {
error: "No AI providers are configured or available. Please check your AI settings."
};
}
} catch (err) {
log.error(`Detailed AI service check failed: ${err}`);
}
return {
error: "AI services are currently unavailable. Please check your configuration."
};
}
// Create direct instance to avoid circular references
const aiManager = new AIServiceManager();
// Get the default service - just use the first available one
const availableProviders = aiManager.getAvailableProviders();
if (availableProviders.length === 0) {
log.error("No AI providers are available after manager check");
return {
error: "No AI providers are configured or available. Please check your AI settings."
};
}
// Use the first available provider
const providerName = availableProviders[0];
log.info(`Using AI provider: ${providerName}`);
// We know the manager has a 'services' property from our code inspection,
// but TypeScript doesn't know that from the interface.
// This is a workaround to access it
const service = (aiManager as any).services[providerName];
if (!service) {
log.error(`AI service for provider ${providerName} not found`);
return {
error: `Selected AI provider (${providerName}) is not available. Please check your configuration.`
};
// Save immediately to ensure user message is saved
await chatStorageService.updateChat(chat.id, chat.messages, chat.title);
log.info(`Added and saved user message: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"`);
}
// Initialize tools
log.info("Initializing LLM agent tools...");
// Ensure tools are initialized to prevent tool execution issues
await ToolHandler.ensureToolsInitialized();
// Create and use the chat pipeline instead of direct processing
// Create and use the chat pipeline
const pipeline = new ChatPipeline({
enableStreaming: req.method === 'GET',
enableMetrics: true,
maxToolCallIterations: 5
});
log.info("Executing chat pipeline...");
// Get user's preferred model
const preferredModel = await this.getPreferredModel();
// Create options object for better tracking
const pipelineOptions = {
// Force useAdvancedContext to be a boolean, no matter what
useAdvancedContext: useAdvancedContext === true,
systemPrompt: session?.messages.find(m => m.role === 'system')?.content,
temperature: session?.metadata.temperature,
maxTokens: session?.metadata.maxTokens,
model: session?.metadata.model,
// Set stream based on request type, but ensure it's explicitly a boolean value
// GET requests or format=stream parameter indicates streaming should be used
systemPrompt: chat.messages.find(m => m.role === 'system')?.content,
model: preferredModel,
stream: !!(req.method === 'GET' || req.query.format === 'stream' || req.query.stream === 'true'),
// Include chatNoteId for tracking tool executions
chatNoteId: chatNoteId
};
// Log the options to verify what's being sent to the pipeline
log.info(`Pipeline input options: ${JSON.stringify({
useAdvancedContext: pipelineOptions.useAdvancedContext,
stream: pipelineOptions.stream
})}`);
log.info(`Pipeline options: ${JSON.stringify({ useAdvancedContext: pipelineOptions.useAdvancedContext, stream: pipelineOptions.stream })}`);
// Import the WebSocket service for direct access
// Import WebSocket service for streaming
const wsService = await import('../../ws.js');
const accumulatedContentRef = { value: '' };
// Create a stream callback wrapper
// This will ensure we properly handle all streaming messages
let messageContent = '';
// Prepare the pipeline input
const pipelineInput: ChatPipelineInput = {
messages: session.messages.map(msg => ({
messages: chat.messages.map(msg => ({
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content
})),
query: content || '', // Ensure query is always a string, even if content is null/undefined
noteId: session.noteContext ?? undefined,
query: content || '',
noteId: undefined, // TODO: Add context note support if needed
showThinking: showThinking,
options: pipelineOptions,
streamCallback: req.method === 'GET' ? (data, done, rawChunk) => {
try {
// Use WebSocket service to send messages
this.handleStreamCallback(
data, done, rawChunk,
wsService.default, chatNoteId,
messageContent, session, res
);
} catch (error) {
log.error(`Error in stream callback: ${error}`);
// Try to send error message
try {
wsService.default.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
error: `Stream error: ${error instanceof Error ? error.message : 'Unknown error'}`,
done: true
});
// End the response
res.write(`data: ${JSON.stringify({ error: 'Stream error', done: true })}\n\n`);
res.end();
} catch (e) {
log.error(`Failed to send error message: ${e}`);
}
}
this.handleStreamCallback(data, done, rawChunk, wsService.default, chatNoteId, res, accumulatedContentRef, chat);
} : undefined
};
// Execute the pipeline
const response = await pipeline.execute(pipelineInput);
// Handle the response
if (req.method === 'POST') {
// Add assistant message to session
session.messages.push({
// Add assistant response to chat
chat.messages.push({
role: 'assistant',
content: response.text || '',
timestamp: new Date()
content: response.text || ''
});
// Extract sources if they're available
// Save the updated chat back to storage (single source of truth)
await chatStorageService.updateChat(chat.id, chat.messages, chat.title);
log.info(`Saved non-streaming assistant response: ${(response.text || '').length} characters`);
// Extract sources if available
const sources = (response as any).sources || [];
// Store sources in the session metadata if they're present
if (sources.length > 0) {
session.metadata.sources = sources;
log.info(`Stored ${sources.length} sources in session metadata`);
}
// Return the response with complete metadata
return {
content: response.text || '',
sources: sources,
metadata: {
model: response.model || session.metadata.model,
provider: response.provider || session.metadata.provider,
temperature: session.metadata.temperature,
maxTokens: session.metadata.maxTokens,
lastUpdated: new Date().toISOString(),
toolExecutions: session.metadata.toolExecutions || []
model: response.model,
provider: response.provider,
lastUpdated: new Date().toISOString()
}
};
} else {
// For streaming requests, we've already sent the response
// For streaming, response is already sent via WebSocket/SSE
// The accumulatedContentRef will have been saved in handleStreamCallback when done=true
return null;
}
} catch (processingError: any) {
log.error(`Error processing message: ${processingError}`);
return {
error: `Error processing your request: ${processingError.message}`
};
} catch (error: any) {
log.error(`Error processing message: ${error}`);
return { error: `Error processing your request: ${error.message}` };
}
}
/**
* Handle stream callback for WebSocket communication
* Simplified stream callback handler
*/
private handleStreamCallback(
private async handleStreamCallback(
data: string | null,
done: boolean,
rawChunk: any,
wsService: any,
chatNoteId: string,
messageContent: string,
session: any,
res: Response
res: Response,
accumulatedContentRef: { value: string },
chat: { id: string; messages: Message[]; title: string }
) {
// Only accumulate content that's actually text (not tool execution or thinking info)
if (data) {
messageContent += data;
}
// Create a message object with all necessary fields
const message: LLMStreamMessage = {
type: 'llm-stream',
chatNoteId: chatNoteId
chatNoteId: chatNoteId,
done: done
};
// Add content if available - either the new chunk or full content on completion
if (data) {
message.content = data;
// Simple accumulation - just append the new data
accumulatedContentRef.value += data;
}
// Add thinking info if available in the raw chunk
// Only include thinking if explicitly present in rawChunk
if (rawChunk && 'thinking' in rawChunk && rawChunk.thinking) {
message.thinking = rawChunk.thinking as string;
}
// Add tool execution info if available in the raw chunk
// Only include tool execution if explicitly present in rawChunk
if (rawChunk && 'toolExecution' in rawChunk && rawChunk.toolExecution) {
// Transform the toolExecution to match the expected format
const toolExec = rawChunk.toolExecution;
message.toolExecution = {
// Use optional chaining for all properties
tool: typeof toolExec.tool === 'string'
? toolExec.tool
: toolExec.tool?.name,
tool: typeof toolExec.tool === 'string' ? toolExec.tool : toolExec.tool?.name,
result: toolExec.result,
// Map arguments to args
args: 'arguments' in toolExec ?
(typeof toolExec.arguments === 'object' ?
toolExec.arguments as Record<string, unknown> : {}) : {},
// Add additional properties if they exist
(typeof toolExec.arguments === 'object' ? toolExec.arguments as Record<string, unknown> : {}) : {},
action: 'action' in toolExec ? toolExec.action as string : undefined,
toolCallId: 'toolCallId' in toolExec ? toolExec.toolCallId as string : undefined,
error: 'error' in toolExec ? toolExec.error as string : undefined
};
}
// Set done flag explicitly
message.done = done;
// On final message, include the complete content too
if (done) {
// Store the response in the session when done
session.messages.push({
role: 'assistant',
content: messageContent,
timestamp: new Date()
});
}
// Send message to all clients
// Send WebSocket message
wsService.sendMessageToAllClients(message);
// Log what was sent (first message and completion)
if (message.thinking || done) {
log.info(
`[WS-SERVER] Sending LLM stream message: chatNoteId=${chatNoteId}, content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${done}`
);
}
// For GET requests, also send as server-sent events
// Prepare response data for JSON event
const responseData: any = {
content: data,
done
};
// Add tool execution if available
// Send SSE response for compatibility
const responseData: any = { content: data, done };
if (rawChunk?.toolExecution) {
responseData.toolExecution = rawChunk.toolExecution;
}
// Send the data as a JSON event
res.write(`data: ${JSON.stringify(responseData)}\n\n`);
// When streaming is complete, save the accumulated content to the chat note
if (done) {
try {
// Only save if we have accumulated content
if (accumulatedContentRef.value) {
// Add assistant response to chat
chat.messages.push({
role: 'assistant',
content: accumulatedContentRef.value
});
// Save the updated chat back to storage
await chatStorageService.updateChat(chat.id, chat.messages, chat.title);
log.info(`Saved streaming assistant response: ${accumulatedContentRef.value.length} characters`);
}
} catch (error) {
// Log error but don't break the response flow
log.error(`Error saving streaming response: ${error}`);
}
// End the response
res.end();
}
}
/**
* Create a new chat session
* Create a new chat
*/
async createSession(req: Request, res: Response) {
try {
const options: any = req.body || {};
const title = options.title || 'Chat Session';
// Use the currentNoteId as the chatNoteId if provided
let chatNoteId = options.chatNoteId;
let noteId = options.noteId || options.chatNoteId;
// If currentNoteId is provided but chatNoteId is not, use currentNoteId
if (!chatNoteId && options.currentNoteId) {
chatNoteId = options.currentNoteId;
log.info(`Using provided currentNoteId ${chatNoteId} as chatNoteId`);
// Check if currentNoteId is already an AI Chat note
if (!noteId && options.currentNoteId) {
const becca = (await import('../../../becca/becca.js')).default;
const note = becca.notes[options.currentNoteId];
if (note) {
try {
const content = note.getContent();
if (content) {
const contentStr = typeof content === 'string' ? content : content.toString();
const parsedContent = JSON.parse(contentStr);
if (parsedContent.messages && Array.isArray(parsedContent.messages)) {
noteId = options.currentNoteId;
log.info(`Using existing AI Chat note ${noteId} as session`);
}
}
} catch (_) {
// Not JSON content, so not an AI Chat note
}
}
}
// If we still don't have a chatNoteId, create a new Chat Note
if (!chatNoteId) {
// Create a new Chat Note via the storage service
const chatStorageService = (await import('../../llm/chat_storage_service.js')).default;
// Create new chat if needed
if (!noteId) {
const newChat = await chatStorageService.createChat(title);
chatNoteId = newChat.id;
log.info(`Created new Chat Note with ID: ${chatNoteId}`);
noteId = newChat.id;
log.info(`Created new Chat Note with ID: ${noteId}`);
} else {
log.info(`Using existing Chat Note with ID: ${noteId}`);
}
// Create a new session through our session store
const session = SessionsStore.createSession({
chatNoteId,
title,
systemPrompt: options.systemPrompt,
contextNoteId: options.contextNoteId,
maxTokens: options.maxTokens,
model: options.model,
provider: options.provider,
temperature: options.temperature
});
return {
id: session.id,
title: session.title,
createdAt: session.createdAt,
noteId: chatNoteId // Return the note ID explicitly
id: noteId,
title: title,
createdAt: new Date(),
noteId: noteId
};
} catch (error: any) {
log.error(`Error creating LLM session: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to create LLM session: ${error.message || 'Unknown error'}`);
log.error(`Error creating chat session: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to create chat session: ${error.message || 'Unknown error'}`);
}
}
/**
* Get a specific chat session by ID
* Get a chat by ID
*/
async getSession(req: Request, res: Response) {
async getSession(req: Request, res: Response): Promise<any> {
try {
const { sessionId } = req.params;
// Check if session exists
const session = SessionsStore.getSession(sessionId);
if (!session) {
// Instead of throwing an error, return a structured 404 response
// that the frontend can handle gracefully
const chat = await chatStorageService.getChat(sessionId);
if (!chat) {
res.status(404).json({
error: true,
message: `Session with ID ${sessionId} not found`,
code: 'session_not_found',
sessionId
});
return null; // Return null to prevent further processing
return null;
}
// Return session with metadata and additional fields
return {
id: session.id,
title: session.title,
createdAt: session.createdAt,
lastActive: session.lastActive,
messages: session.messages,
noteContext: session.noteContext,
// Include additional fields for the frontend
sources: session.metadata.sources || [],
metadata: {
model: session.metadata.model,
provider: session.metadata.provider,
temperature: session.metadata.temperature,
maxTokens: session.metadata.maxTokens,
lastUpdated: session.lastActive.toISOString(),
// Include simplified tool executions if available
toolExecutions: session.metadata.toolExecutions || []
}
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
lastActive: chat.updatedAt,
messages: chat.messages,
metadata: chat.metadata || {}
};
} catch (error: any) {
log.error(`Error getting LLM session: ${error.message || 'Unknown error'}`);
log.error(`Error getting chat session: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to get session: ${error.message || 'Unknown error'}`);
}
}
/**
* Delete a chat session
* Delete a chat
*/
async deleteSession(req: Request, res: Response) {
try {
const { sessionId } = req.params;
// Delete the session
const success = SessionsStore.deleteSession(sessionId);
const success = await chatStorageService.deleteChat(sessionId);
if (!success) {
throw new Error(`Session with ID ${sessionId} not found`);
}
@@ -587,91 +388,47 @@ class RestChatService {
message: `Session ${sessionId} deleted successfully`
};
} catch (error: any) {
log.error(`Error deleting LLM session: ${error.message || 'Unknown error'}`);
log.error(`Error deleting chat session: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to delete session: ${error.message || 'Unknown error'}`);
}
}
/**
* Get all sessions
* Get all chats
*/
getSessions() {
return SessionsStore.getAllSessions();
}
/**
* Create an in-memory session from a Chat Note
* This treats the Chat Note as the source of truth, using its ID as the session ID
*/
async createSessionFromChatNote(noteId: string): Promise<ChatSession | null> {
async getAllSessions() {
try {
log.info(`Creating in-memory session for Chat Note ID ${noteId}`);
// Import chat storage service
const chatStorageService = (await import('../../llm/chat_storage_service.js')).default;
// Try to get the Chat Note data
const chatNote = await chatStorageService.getChat(noteId);
if (!chatNote) {
log.error(`Chat Note ${noteId} not found, cannot create session`);
return null;
}
log.info(`Found Chat Note ${noteId}, creating in-memory session`);
// Convert Message[] to ChatMessage[] by ensuring the role is compatible
const chatMessages: ChatMessage[] = chatNote.messages.map(msg => ({
role: msg.role === 'tool' ? 'assistant' : msg.role, // Map 'tool' role to 'assistant'
content: msg.content,
timestamp: new Date()
}));
// Create a new session with the same ID as the Chat Note
const session: ChatSession = {
id: chatNote.id, // Use Chat Note ID as the session ID
title: chatNote.title,
messages: chatMessages,
createdAt: chatNote.createdAt || new Date(),
lastActive: new Date(),
metadata: chatNote.metadata || {}
const chats = await chatStorageService.getAllChats();
return {
sessions: chats.map(chat => ({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
lastActive: chat.updatedAt,
messageCount: chat.messages.length
}))
};
// Add the session to the in-memory store
SessionsStore.getAllSessions().set(noteId, session);
log.info(`Successfully created in-memory session for Chat Note ${noteId}`);
return session;
} catch (error) {
log.error(`Failed to create session from Chat Note: ${error}`);
return null;
} catch (error: any) {
log.error(`Error listing sessions: ${error}`);
throw new Error(`Failed to list sessions: ${error}`);
}
}
/**
* Get an existing session or create a new one from a Chat Note
* This treats the Chat Note as the source of truth, using its ID as the session ID
* Get the user's preferred model
*/
async getOrCreateSessionFromChatNote(noteId: string, createIfNotFound: boolean = true): Promise<ChatSession | null> {
// First check if we already have this session in memory
let session = SessionsStore.getSession(noteId);
if (session) {
log.info(`Found existing in-memory session for Chat Note ${noteId}`);
return session;
async getPreferredModel(): Promise<string | undefined> {
try {
const validConfig = await getFirstValidModelConfig();
if (!validConfig) {
log.error('No valid AI model configuration found');
return undefined;
}
return validConfig.model;
} catch (error) {
log.error(`Error getting preferred model: ${error}`);
return undefined;
}
// If not in memory, try to create from Chat Note
log.info(`Session not found in memory for Chat Note ${noteId}, attempting to create it`);
// Only try to create if allowed
if (!createIfNotFound) {
log.info(`Not creating new session for ${noteId} as createIfNotFound=false`);
return null;
}
// Create from Chat Note
return await this.createSessionFromChatNote(noteId);
}
}

View File

@@ -1,169 +0,0 @@
/**
* In-memory storage for chat sessions
*/
import log from "../../log.js";
import { LLM_CONSTANTS } from '../constants/provider_constants.js';
import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
import { randomString } from "../../utils.js";
import type { ChatSession, ChatMessage } from '../interfaces/chat_session.js';
// In-memory storage for sessions
const sessions = new Map<string, ChatSession>();
// Flag to track if cleanup timer has been initialized
let cleanupInitialized = false;
/**
* Provides methods to manage chat sessions
*/
class SessionsStore {
/**
* Initialize the session cleanup timer to remove old/inactive sessions
*/
initializeCleanupTimer(): void {
if (cleanupInitialized) {
return;
}
// Clean sessions that have expired based on the constants
function cleanupOldSessions() {
const expiryTime = new Date(Date.now() - LLM_CONSTANTS.SESSION.SESSION_EXPIRY_MS);
for (const [sessionId, session] of sessions.entries()) {
if (session.lastActive < expiryTime) {
sessions.delete(sessionId);
}
}
}
// Run cleanup at the configured interval
setInterval(cleanupOldSessions, LLM_CONSTANTS.SESSION.CLEANUP_INTERVAL_MS);
cleanupInitialized = true;
log.info("Session cleanup timer initialized");
}
/**
* Get all sessions
*/
getAllSessions(): Map<string, ChatSession> {
return sessions;
}
/**
* Get a specific session by ID
*/
getSession(sessionId: string): ChatSession | undefined {
return sessions.get(sessionId);
}
/**
* Create a new session
*/
createSession(options: {
chatNoteId: string;
title?: string;
systemPrompt?: string;
contextNoteId?: string;
maxTokens?: number;
model?: string;
provider?: string;
temperature?: number;
}): ChatSession {
this.initializeCleanupTimer();
const title = options.title || 'Chat Session';
const sessionId = options.chatNoteId;
const now = new Date();
// Initial system message if provided
const messages: ChatMessage[] = [];
if (options.systemPrompt) {
messages.push({
role: 'system',
content: options.systemPrompt,
timestamp: now
});
}
// Create and store the session
const session: ChatSession = {
id: sessionId,
title,
messages,
createdAt: now,
lastActive: now,
noteContext: options.contextNoteId,
metadata: {
temperature: options.temperature || SEARCH_CONSTANTS.TEMPERATURE.DEFAULT,
maxTokens: options.maxTokens,
model: options.model,
provider: options.provider,
sources: [],
toolExecutions: [],
lastUpdated: now.toISOString()
}
};
sessions.set(sessionId, session);
log.info(`Created in-memory session for Chat Note ID: ${sessionId}`);
return session;
}
/**
* Update a session's last active timestamp
*/
touchSession(sessionId: string): boolean {
const session = sessions.get(sessionId);
if (!session) {
return false;
}
session.lastActive = new Date();
return true;
}
/**
* Delete a session
*/
deleteSession(sessionId: string): boolean {
return sessions.delete(sessionId);
}
/**
* Record a tool execution in the session metadata
*/
recordToolExecution(chatNoteId: string, tool: any, result: string, error?: string): void {
if (!chatNoteId) return;
const session = sessions.get(chatNoteId);
if (!session) return;
try {
const toolExecutions = session.metadata.toolExecutions || [];
// Format tool execution record
const execution = {
id: tool.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
name: tool.function?.name || 'unknown',
arguments: typeof tool.function?.arguments === 'string'
? (() => { try { return JSON.parse(tool.function.arguments); } catch { return tool.function.arguments; } })()
: tool.function?.arguments || {},
result: result,
error: error,
timestamp: new Date().toISOString()
};
// Add to tool executions
toolExecutions.push(execution);
session.metadata.toolExecutions = toolExecutions;
log.info(`Recorded tool execution for ${execution.name} in session ${chatNoteId}`);
} catch (err) {
log.error(`Failed to record tool execution: ${err}`);
}
}
}
// Create singleton instance
const sessionsStore = new SessionsStore();
export default sessionsStore;

View File

@@ -0,0 +1,179 @@
import configurationManager from './configuration_manager.js';
import type {
ProviderType,
ModelIdentifier,
ModelConfig,
ProviderPrecedenceConfig,
EmbeddingProviderPrecedenceConfig
} from '../interfaces/configuration_interfaces.js';
/**
* Helper functions for accessing AI configuration without string parsing
* Use these throughout the codebase instead of parsing strings directly
*/
/**
* Get the ordered list of AI providers
*/
export async function getProviderPrecedence(): Promise<ProviderType[]> {
const config = await configurationManager.getProviderPrecedence();
return config.providers;
}
/**
* Get the default/preferred AI provider
*/
export async function getPreferredProvider(): Promise<ProviderType | null> {
const config = await configurationManager.getProviderPrecedence();
if (config.providers.length === 0) {
return null; // No providers configured
}
return config.defaultProvider || config.providers[0];
}
/**
* Get the ordered list of embedding providers
*/
export async function getEmbeddingProviderPrecedence(): Promise<string[]> {
const config = await configurationManager.getEmbeddingProviderPrecedence();
return config.providers;
}
/**
* Get the default embedding provider
*/
export async function getPreferredEmbeddingProvider(): Promise<string | null> {
const config = await configurationManager.getEmbeddingProviderPrecedence();
if (config.providers.length === 0) {
return null; // No providers configured
}
return config.defaultProvider || config.providers[0];
}
/**
* Parse a model identifier (handles "provider:model" format)
*/
export function parseModelIdentifier(modelString: string): ModelIdentifier {
return configurationManager.parseModelIdentifier(modelString);
}
/**
* Create a model configuration from a model string
*/
export function createModelConfig(modelString: string, defaultProvider?: ProviderType): ModelConfig {
return configurationManager.createModelConfig(modelString, defaultProvider);
}
/**
* Get the default model for a specific provider
*/
export async function getDefaultModelForProvider(provider: ProviderType): Promise<string | undefined> {
const config = await configurationManager.getAIConfig();
return config.defaultModels[provider]; // This can now be undefined
}
/**
* Get provider settings for a specific provider
*/
export async function getProviderSettings(provider: ProviderType) {
const config = await configurationManager.getAIConfig();
return config.providerSettings[provider];
}
/**
* Check if AI is enabled
*/
export async function isAIEnabled(): Promise<boolean> {
const config = await configurationManager.getAIConfig();
return config.enabled;
}
/**
* Check if a provider has required configuration
*/
export async function isProviderConfigured(provider: ProviderType): Promise<boolean> {
const settings = await getProviderSettings(provider);
switch (provider) {
case 'openai':
return Boolean((settings as any)?.apiKey);
case 'anthropic':
return Boolean((settings as any)?.apiKey);
case 'ollama':
return Boolean((settings as any)?.baseUrl);
default:
return false;
}
}
/**
* Get the first available (configured) provider from the precedence list
*/
export async function getFirstAvailableProvider(): Promise<ProviderType | null> {
const providers = await getProviderPrecedence();
if (providers.length === 0) {
return null; // No providers configured
}
for (const provider of providers) {
if (await isProviderConfigured(provider)) {
return provider;
}
}
return null; // No providers are properly configured
}
/**
* Validate the current AI configuration
*/
export async function validateConfiguration() {
return configurationManager.validateConfig();
}
/**
* Clear cached configuration (use when settings change)
*/
export function clearConfigurationCache(): void {
configurationManager.clearCache();
}
/**
* Get a model configuration with validation that no defaults are assumed
*/
export async function getValidModelConfig(provider: ProviderType): Promise<{ model: string; provider: ProviderType } | null> {
const defaultModel = await getDefaultModelForProvider(provider);
if (!defaultModel) {
// No default model configured for this provider
return null;
}
const isConfigured = await isProviderConfigured(provider);
if (!isConfigured) {
// Provider is not properly configured
return null;
}
return {
model: defaultModel,
provider
};
}
/**
* Get the first valid model configuration from the provider precedence list
*/
export async function getFirstValidModelConfig(): Promise<{ model: string; provider: ProviderType } | null> {
const providers = await getProviderPrecedence();
for (const provider of providers) {
const config = await getValidModelConfig(provider);
if (config) {
return config;
}
}
return null; // No valid model configuration found
}

View File

@@ -0,0 +1,378 @@
import options from '../../options.js';
import log from '../../log.js';
import type {
AIConfig,
ProviderPrecedenceConfig,
EmbeddingProviderPrecedenceConfig,
ModelIdentifier,
ModelConfig,
ProviderType,
EmbeddingProviderType,
ConfigValidationResult,
ProviderSettings,
OpenAISettings,
AnthropicSettings,
OllamaSettings
} from '../interfaces/configuration_interfaces.js';
/**
* Configuration manager that handles conversion from string-based options
* to proper typed configuration objects.
*
* This is the ONLY place where string parsing should happen for LLM configurations.
*/
export class ConfigurationManager {
private static instance: ConfigurationManager | null = null;
private cachedConfig: AIConfig | null = null;
private lastConfigUpdate: number = 0;
// Cache for 5 minutes to avoid excessive option reads
private static readonly CACHE_DURATION = 5 * 60 * 1000;
private constructor() {}
public static getInstance(): ConfigurationManager {
if (!ConfigurationManager.instance) {
ConfigurationManager.instance = new ConfigurationManager();
}
return ConfigurationManager.instance;
}
/**
* Get the complete AI configuration
*/
public async getAIConfig(): Promise<AIConfig> {
const now = Date.now();
if (this.cachedConfig && (now - this.lastConfigUpdate) < ConfigurationManager.CACHE_DURATION) {
return this.cachedConfig;
}
try {
const config: AIConfig = {
enabled: await this.getAIEnabled(),
providerPrecedence: await this.getProviderPrecedence(),
embeddingProviderPrecedence: await this.getEmbeddingProviderPrecedence(),
defaultModels: await this.getDefaultModels(),
providerSettings: await this.getProviderSettings()
};
this.cachedConfig = config;
this.lastConfigUpdate = now;
return config;
} catch (error) {
log.error(`Error loading AI configuration: ${error}`);
return this.getDefaultConfig();
}
}
/**
* Parse provider precedence from string option
*/
public async getProviderPrecedence(): Promise<ProviderPrecedenceConfig> {
try {
const precedenceOption = await options.getOption('aiProviderPrecedence');
const providers = this.parseProviderList(precedenceOption);
return {
providers: providers as ProviderType[],
defaultProvider: providers.length > 0 ? providers[0] as ProviderType : undefined
};
} catch (error) {
log.error(`Error parsing provider precedence: ${error}`);
// Only return known providers if they exist, don't assume defaults
return {
providers: [],
defaultProvider: undefined
};
}
}
/**
* Parse embedding provider precedence from string option
*/
public async getEmbeddingProviderPrecedence(): Promise<EmbeddingProviderPrecedenceConfig> {
try {
const precedenceOption = await options.getOption('embeddingProviderPrecedence');
const providers = this.parseProviderList(precedenceOption);
return {
providers: providers as EmbeddingProviderType[],
defaultProvider: providers.length > 0 ? providers[0] as EmbeddingProviderType : undefined
};
} catch (error) {
log.error(`Error parsing embedding provider precedence: ${error}`);
// Don't assume defaults, return empty configuration
return {
providers: [],
defaultProvider: undefined
};
}
}
/**
* Parse model identifier with optional provider prefix
* Handles formats like "gpt-4", "openai:gpt-4", "ollama:llama2:7b"
*/
public parseModelIdentifier(modelString: string): ModelIdentifier {
if (!modelString) {
return {
modelId: '',
fullIdentifier: ''
};
}
const parts = modelString.split(':');
if (parts.length === 1) {
// No provider prefix, just model name
return {
modelId: modelString,
fullIdentifier: modelString
};
}
// Check if first part is a known provider
const potentialProvider = parts[0].toLowerCase();
const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama'];
if (knownProviders.includes(potentialProvider as ProviderType)) {
// Provider prefix format
const provider = potentialProvider as ProviderType;
const modelId = parts.slice(1).join(':'); // Rejoin in case model has colons
return {
provider,
modelId,
fullIdentifier: modelString
};
}
// Not a provider prefix, treat whole string as model name
return {
modelId: modelString,
fullIdentifier: modelString
};
}
/**
* Create model configuration from string
*/
public createModelConfig(modelString: string, defaultProvider?: ProviderType): ModelConfig {
const identifier = this.parseModelIdentifier(modelString);
const provider = identifier.provider || defaultProvider || 'openai';
return {
provider,
modelId: identifier.modelId,
displayName: identifier.fullIdentifier
};
}
/**
* Get default models for each provider - ONLY from user configuration
*/
public async getDefaultModels(): Promise<Record<ProviderType, string | undefined>> {
try {
const [openaiModel, anthropicModel, ollamaModel] = await Promise.all([
options.getOption('openaiDefaultModel'),
options.getOption('anthropicDefaultModel'),
options.getOption('ollamaDefaultModel')
]);
return {
openai: openaiModel || undefined,
anthropic: anthropicModel || undefined,
ollama: ollamaModel || undefined
};
} catch (error) {
log.error(`Error loading default models: ${error}`);
// Return undefined for all providers if we can't load config
return {
openai: undefined,
anthropic: undefined,
ollama: undefined
};
}
}
/**
* Get provider-specific settings
*/
public async getProviderSettings(): Promise<ProviderSettings> {
try {
const [
openaiApiKey, openaiBaseUrl, openaiDefaultModel,
anthropicApiKey, anthropicBaseUrl, anthropicDefaultModel,
ollamaBaseUrl, ollamaDefaultModel
] = await Promise.all([
options.getOption('openaiApiKey'),
options.getOption('openaiBaseUrl'),
options.getOption('openaiDefaultModel'),
options.getOption('anthropicApiKey'),
options.getOption('anthropicBaseUrl'),
options.getOption('anthropicDefaultModel'),
options.getOption('ollamaBaseUrl'),
options.getOption('ollamaDefaultModel')
]);
const settings: ProviderSettings = {};
if (openaiApiKey || openaiBaseUrl || openaiDefaultModel) {
settings.openai = {
apiKey: openaiApiKey,
baseUrl: openaiBaseUrl,
defaultModel: openaiDefaultModel
};
}
if (anthropicApiKey || anthropicBaseUrl || anthropicDefaultModel) {
settings.anthropic = {
apiKey: anthropicApiKey,
baseUrl: anthropicBaseUrl,
defaultModel: anthropicDefaultModel
};
}
if (ollamaBaseUrl || ollamaDefaultModel) {
settings.ollama = {
baseUrl: ollamaBaseUrl,
defaultModel: ollamaDefaultModel
};
}
return settings;
} catch (error) {
log.error(`Error loading provider settings: ${error}`);
return {};
}
}
/**
* Validate configuration
*/
public async validateConfig(): Promise<ConfigValidationResult> {
const result: ConfigValidationResult = {
isValid: true,
errors: [],
warnings: []
};
try {
const config = await this.getAIConfig();
if (!config.enabled) {
result.warnings.push('AI features are disabled');
return result;
}
// Validate provider precedence
if (config.providerPrecedence.providers.length === 0) {
result.errors.push('No providers configured in precedence list');
result.isValid = false;
}
// Validate provider settings
for (const provider of config.providerPrecedence.providers) {
const providerConfig = config.providerSettings[provider];
if (provider === 'openai') {
const openaiConfig = providerConfig as OpenAISettings | undefined;
if (!openaiConfig?.apiKey) {
result.warnings.push('OpenAI API key is not configured');
}
}
if (provider === 'anthropic') {
const anthropicConfig = providerConfig as AnthropicSettings | undefined;
if (!anthropicConfig?.apiKey) {
result.warnings.push('Anthropic API key is not configured');
}
}
if (provider === 'ollama') {
const ollamaConfig = providerConfig as OllamaSettings | undefined;
if (!ollamaConfig?.baseUrl) {
result.warnings.push('Ollama base URL is not configured');
}
}
}
} catch (error) {
result.errors.push(`Configuration validation error: ${error}`);
result.isValid = false;
}
return result;
}
/**
* Clear cached configuration (force reload on next access)
*/
public clearCache(): void {
this.cachedConfig = null;
this.lastConfigUpdate = 0;
}
// Private helper methods
private async getAIEnabled(): Promise<boolean> {
try {
return await options.getOptionBool('aiEnabled');
} catch {
return false;
}
}
private parseProviderList(precedenceOption: string | null): string[] {
if (!precedenceOption) {
// Don't assume any defaults - return empty array
return [];
}
try {
// Handle JSON array format
if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) {
const parsed = JSON.parse(precedenceOption);
if (Array.isArray(parsed)) {
return parsed.map(p => String(p).trim());
}
}
// Handle comma-separated format
if (precedenceOption.includes(',')) {
return precedenceOption.split(',').map(p => p.trim());
}
// Handle single provider
return [precedenceOption.trim()];
} catch (error) {
log.error(`Error parsing provider list "${precedenceOption}": ${error}`);
// Don't assume defaults on parse error
return [];
}
}
private getDefaultConfig(): AIConfig {
return {
enabled: false,
providerPrecedence: {
providers: [],
defaultProvider: undefined
},
embeddingProviderPrecedence: {
providers: [],
defaultProvider: undefined
},
defaultModels: {
openai: undefined,
anthropic: undefined,
ollama: undefined
},
providerSettings: {}
};
}
}
// Export singleton instance
export default ConfigurationManager.getInstance();

View File

@@ -42,7 +42,6 @@ export class AgentToolsManager {
}
try {
log.info("Initializing agent tools");
// Initialize the context service first
try {

View File

@@ -1,4 +1,4 @@
import sql from "../../sql.js";
import sql from '../../sql.js'
import { randomString } from "../../../services/utils.js";
import dateUtils from "../../../services/date_utils.js";
import log from "../../log.js";
@@ -11,6 +11,7 @@ import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
import type { NoteEmbeddingContext } from "./embeddings_interface.js";
import becca from "../../../becca/becca.js";
import { isNoteExcludedFromAIById } from "../utils/ai_exclusion_utils.js";
import { getEmbeddingProviderPrecedence } from '../config/configuration_helpers.js';
interface Similarity {
noteId: string;
@@ -271,44 +272,28 @@ export async function findSimilarNotes(
}
}
} else {
// Use dedicated embedding provider precedence from options for other strategies
let preferredProviders: string[] = [];
const embeddingPrecedence = await options.getOption('embeddingProviderPrecedence');
// Try providers using the new configuration system
if (useFallback) {
log.info('No embeddings found for specified provider, trying fallback providers...');
if (embeddingPrecedence) {
// For "comma,separated,values"
if (embeddingPrecedence.includes(',')) {
preferredProviders = embeddingPrecedence.split(',').map(p => p.trim());
}
// For JSON array ["value1", "value2"]
else if (embeddingPrecedence.startsWith('[') && embeddingPrecedence.endsWith(']')) {
try {
preferredProviders = JSON.parse(embeddingPrecedence);
} catch (e) {
log.error(`Error parsing embedding precedence: ${e}`);
preferredProviders = [embeddingPrecedence]; // Fallback to using as single value
// Use the new configuration system - no string parsing!
const preferredProviders = await getEmbeddingProviderPrecedence();
log.info(`Using provider precedence: ${preferredProviders.join(', ')}`);
// Try providers in precedence order
for (const provider of preferredProviders) {
const providerEmbeddings = availableEmbeddings.filter(e => e.providerId === provider);
if (providerEmbeddings.length > 0) {
// Choose the model with the most embeddings
const bestModel = providerEmbeddings.sort((a, b) => b.count - a.count)[0];
log.info(`Found fallback provider: ${provider}, model: ${bestModel.modelId}, dimension: ${bestModel.dimension}`);
// The 'regenerate' strategy would go here if needed
// We're no longer supporting the 'adapt' strategy
}
}
// For a single value
else {
preferredProviders = [embeddingPrecedence];
}
}
log.info(`Using provider precedence: ${preferredProviders.join(', ')}`);
// Try providers in precedence order
for (const provider of preferredProviders) {
const providerEmbeddings = availableEmbeddings.filter(e => e.providerId === provider);
if (providerEmbeddings.length > 0) {
// Choose the model with the most embeddings
const bestModel = providerEmbeddings.sort((a, b) => b.count - a.count)[0];
log.info(`Found fallback provider: ${provider}, model: ${bestModel.modelId}, dimension: ${bestModel.dimension}`);
// The 'regenerate' strategy would go here if needed
// We're no longer supporting the 'adapt' strategy
}
}
}
}

View File

@@ -0,0 +1,108 @@
/**
* Configuration interfaces for LLM services
* These interfaces replace string parsing with proper typed objects
*/
/**
* Provider precedence configuration
*/
export interface ProviderPrecedenceConfig {
providers: ProviderType[];
defaultProvider?: ProviderType;
}
/**
* Model configuration with provider information
*/
export interface ModelConfig {
provider: ProviderType;
modelId: string;
displayName?: string;
capabilities?: ModelCapabilities;
}
/**
* Embedding provider precedence configuration
*/
export interface EmbeddingProviderPrecedenceConfig {
providers: EmbeddingProviderType[];
defaultProvider?: EmbeddingProviderType;
}
/**
* Model capabilities
*/
export interface ModelCapabilities {
contextWindow?: number;
supportsTools?: boolean;
supportsVision?: boolean;
supportsStreaming?: boolean;
maxTokens?: number;
temperature?: number;
}
/**
* Complete AI configuration
*/
export interface AIConfig {
enabled: boolean;
providerPrecedence: ProviderPrecedenceConfig;
embeddingProviderPrecedence: EmbeddingProviderPrecedenceConfig;
defaultModels: Record<ProviderType, string | undefined>;
providerSettings: ProviderSettings;
}
/**
* Provider-specific settings
*/
export interface ProviderSettings {
openai?: OpenAISettings;
anthropic?: AnthropicSettings;
ollama?: OllamaSettings;
}
export interface OpenAISettings {
apiKey?: string;
baseUrl?: string;
defaultModel?: string;
}
export interface AnthropicSettings {
apiKey?: string;
baseUrl?: string;
defaultModel?: string;
}
export interface OllamaSettings {
baseUrl?: string;
defaultModel?: string;
timeout?: number;
}
/**
* Valid provider types
*/
export type ProviderType = 'openai' | 'anthropic' | 'ollama';
/**
* Valid embedding provider types
*/
export type EmbeddingProviderType = 'openai' | 'ollama' | 'local';
/**
* Model identifier with provider prefix (e.g., "openai:gpt-4" or "ollama:llama2")
*/
export interface ModelIdentifier {
provider?: ProviderType;
modelId: string;
fullIdentifier: string; // The complete string representation
}
/**
* Validation result for configuration
*/
export interface ConfigValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}

View File

@@ -20,44 +20,44 @@ export class MessagePreparationStage extends BasePipelineStage<MessagePreparatio
*/
protected async process(input: MessagePreparationInput): Promise<{ messages: Message[] }> {
const { messages, context, systemPrompt, options } = input;
// Determine provider from model string if available (format: "provider:model")
let provider = 'default';
if (options?.model && options.model.includes(':')) {
const [providerName] = options.model.split(':');
provider = providerName;
}
// Check if tools are enabled
const toolsEnabled = options?.enableTools === true;
log.info(`Preparing messages for provider: ${provider}, context: ${!!context}, system prompt: ${!!systemPrompt}, tools: ${toolsEnabled}`);
// Get appropriate formatter for this provider
const formatter = MessageFormatterFactory.getFormatter(provider);
// Determine the system prompt to use
let finalSystemPrompt = systemPrompt || SYSTEM_PROMPTS.DEFAULT_SYSTEM_PROMPT;
// If tools are enabled, enhance system prompt with tools guidance
if (toolsEnabled) {
const toolCount = toolRegistry.getAllTools().length;
const toolsPrompt = `You have access to ${toolCount} tools to help you respond. When you need information that might be in the user's notes, use the search_notes tool to find relevant content or the read_note tool to read a specific note by ID. Use tools when specific information is required rather than making assumptions.`;
// Add tools guidance to system prompt
finalSystemPrompt = finalSystemPrompt + '\n\n' + toolsPrompt;
log.info(`Enhanced system prompt with tools guidance: ${toolCount} tools available`);
}
// Format messages using provider-specific approach
const formattedMessages = formatter.formatMessages(
messages,
finalSystemPrompt,
context
);
log.info(`Formatted ${messages.length} messages into ${formattedMessages.length} messages for provider: ${provider}`);
return { messages: formattedMessages };
}
}

View File

@@ -3,9 +3,22 @@ import type { ModelSelectionInput } from '../interfaces.js';
import type { ChatCompletionOptions } from '../../ai_interface.js';
import type { ModelMetadata } from '../../providers/provider_options.js';
import log from '../../../log.js';
import options from '../../../options.js';
import aiServiceManager from '../../ai_service_manager.js';
import { SEARCH_CONSTANTS, MODEL_CAPABILITIES } from "../../constants/search_constants.js";
// Import types
import type { ServiceProviders } from '../../interfaces/ai_service_interfaces.js';
// Import new configuration system
import {
getProviderPrecedence,
getPreferredProvider,
parseModelIdentifier,
getDefaultModelForProvider,
createModelConfig
} from '../../config/configuration_helpers.js';
import type { ProviderType } from '../../interfaces/configuration_interfaces.js';
/**
* Pipeline stage for selecting the appropriate LLM model
*/
@@ -36,15 +49,15 @@ export class ModelSelectionStage extends BasePipelineStage<ModelSelectionInput,
// If model already specified, don't override it
if (updatedOptions.model) {
// Check if the model has a provider prefix, which indicates legacy format
const modelParts = this.parseModelIdentifier(updatedOptions.model);
// Use the new configuration system to parse model identifier
const modelIdentifier = parseModelIdentifier(updatedOptions.model);
if (modelParts.provider) {
if (modelIdentifier.provider) {
// Add provider metadata for backward compatibility
this.addProviderMetadata(updatedOptions, modelParts.provider, modelParts.model);
this.addProviderMetadata(updatedOptions, modelIdentifier.provider as ServiceProviders, modelIdentifier.modelId);
// Update the model to be just the model name without provider prefix
updatedOptions.model = modelParts.model;
log.info(`Using explicitly specified model: ${modelParts.model} from provider: ${modelParts.provider}`);
updatedOptions.model = modelIdentifier.modelId;
log.info(`Using explicitly specified model: ${modelIdentifier.modelId} from provider: ${modelIdentifier.provider}`);
} else {
log.info(`Using explicitly specified model: ${updatedOptions.model}`);
}
@@ -86,118 +99,72 @@ export class ModelSelectionStage extends BasePipelineStage<ModelSelectionInput,
}
}
// Get default provider and model based on precedence
let defaultProvider = 'openai';
let defaultModelName = 'gpt-3.5-turbo';
// Get default provider and model using the new configuration system
try {
// Get provider precedence list
const providerPrecedence = await options.getOption('aiProviderPrecedence');
if (providerPrecedence) {
// Parse provider precedence list
let providers: string[] = [];
if (providerPrecedence.includes(',')) {
providers = providerPrecedence.split(',').map(p => p.trim());
} else if (providerPrecedence.startsWith('[') && providerPrecedence.endsWith(']')) {
providers = JSON.parse(providerPrecedence);
} else {
providers = [providerPrecedence];
}
// Use the new configuration helpers - no string parsing!
const preferredProvider = await getPreferredProvider();
// Check for first available provider
if (providers.length > 0) {
const firstProvider = providers[0];
defaultProvider = firstProvider;
if (!preferredProvider) {
throw new Error('No AI providers are configured. Please check your AI settings.');
}
// Get provider-specific default model
if (firstProvider === 'openai') {
const model = await options.getOption('openaiDefaultModel');
if (model) defaultModelName = model;
} else if (firstProvider === 'anthropic') {
const model = await options.getOption('anthropicDefaultModel');
if (model) defaultModelName = model;
} else if (firstProvider === 'ollama') {
const model = await options.getOption('ollamaDefaultModel');
if (model) {
defaultModelName = model;
const modelName = await getDefaultModelForProvider(preferredProvider);
// Enable tools for all Ollama models
// The Ollama API will handle models that don't support tool calling
log.info(`Using Ollama model ${model} with tool calling enabled`);
updatedOptions.enableTools = true;
}
}
if (!modelName) {
throw new Error(`No default model configured for provider ${preferredProvider}. Please set a default model in your AI settings.`);
}
log.info(`Selected provider: ${preferredProvider}, model: ${modelName}`);
// Determine query complexity
let queryComplexity = 'low';
if (query) {
// Simple heuristic: longer queries or those with complex terms indicate higher complexity
const complexityIndicators = [
'explain', 'analyze', 'compare', 'evaluate', 'synthesize',
'summarize', 'elaborate', 'investigate', 'research', 'debate'
];
const hasComplexTerms = complexityIndicators.some(term => query.toLowerCase().includes(term));
const isLongQuery = query.length > 100;
const hasMultipleQuestions = (query.match(/\?/g) || []).length > 1;
if ((hasComplexTerms && isLongQuery) || hasMultipleQuestions) {
queryComplexity = 'high';
} else if (hasComplexTerms || isLongQuery) {
queryComplexity = 'medium';
}
}
// Check content length if provided
if (contentLength && contentLength > SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.MEDIUM_THRESHOLD) {
// For large content, favor more powerful models
queryComplexity = contentLength > SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.HIGH_THRESHOLD ? 'high' : 'medium';
}
// Set the model and add provider metadata
updatedOptions.model = modelName;
this.addProviderMetadata(updatedOptions, preferredProvider as ServiceProviders, modelName);
log.info(`Selected model: ${modelName} from provider: ${preferredProvider} for query complexity: ${queryComplexity}`);
log.info(`[ModelSelectionStage] Final options: ${JSON.stringify({
model: updatedOptions.model,
stream: updatedOptions.stream,
provider: preferredProvider,
enableTools: updatedOptions.enableTools
})}`);
return { options: updatedOptions };
} catch (error) {
// If any error occurs, use the fallback default
log.error(`Error determining default model: ${error}`);
}
// Determine query complexity
let queryComplexity = 'low';
if (query) {
// Simple heuristic: longer queries or those with complex terms indicate higher complexity
const complexityIndicators = [
'explain', 'analyze', 'compare', 'evaluate', 'synthesize',
'summarize', 'elaborate', 'investigate', 'research', 'debate'
];
const hasComplexTerms = complexityIndicators.some(term => query.toLowerCase().includes(term));
const isLongQuery = query.length > 100;
const hasMultipleQuestions = (query.match(/\?/g) || []).length > 1;
if ((hasComplexTerms && isLongQuery) || hasMultipleQuestions) {
queryComplexity = 'high';
} else if (hasComplexTerms || isLongQuery) {
queryComplexity = 'medium';
}
}
// Check content length if provided
if (contentLength && contentLength > SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.MEDIUM_THRESHOLD) {
// For large content, favor more powerful models
queryComplexity = contentLength > SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.HIGH_THRESHOLD ? 'high' : 'medium';
}
// Set the model and add provider metadata
updatedOptions.model = defaultModelName;
this.addProviderMetadata(updatedOptions, defaultProvider, defaultModelName);
log.info(`Selected model: ${defaultModelName} from provider: ${defaultProvider} for query complexity: ${queryComplexity}`);
log.info(`[ModelSelectionStage] Final options: ${JSON.stringify({
model: updatedOptions.model,
stream: updatedOptions.stream,
provider: defaultProvider,
enableTools: updatedOptions.enableTools
})}`);
return { options: updatedOptions };
}
/**
* Helper to parse model identifier with provider prefix
* Handles legacy format "provider:model"
*/
private parseModelIdentifier(modelId: string): { provider?: string, model: string } {
if (!modelId) return { model: '' };
const parts = modelId.split(':');
if (parts.length === 1) {
// No provider prefix
return { model: modelId };
} else {
// Extract provider and model
const provider = parts[0];
const model = parts.slice(1).join(':'); // Handle model names that might include :
return { provider, model };
throw new Error(`Failed to determine AI model configuration: ${error}`);
}
}
/**
* Add provider metadata to the options based on model name
*/
private addProviderMetadata(options: ChatCompletionOptions, provider: string, modelName: string): void {
private addProviderMetadata(options: ChatCompletionOptions, provider: ServiceProviders, modelName: string): void {
// Check if we already have providerMetadata
if (options.providerMetadata) {
// If providerMetadata exists but not modelId, add the model name
@@ -216,7 +183,7 @@ export class ModelSelectionStage extends BasePipelineStage<ModelSelectionInput,
// Find the first available provider
for (const p of providerPrecedence) {
if (aiServiceManager.isProviderAvailable(p)) {
selectedProvider = p;
selectedProvider = p as ServiceProviders;
break;
}
}
@@ -234,7 +201,8 @@ export class ModelSelectionStage extends BasePipelineStage<ModelSelectionInput,
// For backward compatibility, ensure model name is set without prefix
if (options.model && options.model.includes(':')) {
options.model = modelName || options.model.split(':')[1];
const parsed = parseModelIdentifier(options.model);
options.model = modelName || parsed.modelId;
}
log.info(`Set provider metadata: provider=${selectedProvider}, model=${modelName}`);
@@ -242,33 +210,43 @@ export class ModelSelectionStage extends BasePipelineStage<ModelSelectionInput,
}
/**
* Determine model based on provider precedence
* Determine model based on provider precedence using the new configuration system
*/
private determineDefaultModel(input: ModelSelectionInput): string {
const providerPrecedence = ['anthropic', 'openai', 'ollama'];
private async determineDefaultModel(input: ModelSelectionInput): Promise<string> {
try {
// Use the new configuration system
const providers = await getProviderPrecedence();
// Use only providers that are available
const availableProviders = providerPrecedence.filter(provider =>
aiServiceManager.isProviderAvailable(provider));
// Use only providers that are available
const availableProviders = providers.filter(provider =>
aiServiceManager.isProviderAvailable(provider));
if (availableProviders.length === 0) {
throw new Error('No AI providers are available');
if (availableProviders.length === 0) {
throw new Error('No AI providers are available');
}
// Get the first available provider and its default model
const defaultProvider = availableProviders[0];
const defaultModel = await getDefaultModelForProvider(defaultProvider);
if (!defaultModel) {
throw new Error(`No default model configured for provider ${defaultProvider}. Please configure a default model in your AI settings.`);
}
// Set provider metadata
if (!input.options.providerMetadata) {
input.options.providerMetadata = {
provider: defaultProvider as 'openai' | 'anthropic' | 'ollama' | 'local',
modelId: defaultModel
};
}
log.info(`Selected default model ${defaultModel} from provider ${defaultProvider}`);
return defaultModel;
} catch (error) {
log.error(`Error determining default model: ${error}`);
throw error; // Don't provide fallback defaults, let the error propagate
}
// Get the first available provider and its default model
const defaultProvider = availableProviders[0] as 'openai' | 'anthropic' | 'ollama' | 'local';
let defaultModel = 'gpt-3.5-turbo'; // Use model from our constants
// Set provider metadata
if (!input.options.providerMetadata) {
input.options.providerMetadata = {
provider: defaultProvider,
modelId: defaultModel
};
}
log.info(`Selected default model ${defaultModel} from provider ${defaultProvider}`);
return defaultModel;
}
/**

View File

@@ -559,11 +559,9 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
// Get agent tools manager and initialize it
const agentTools = aiServiceManager.getAgentTools();
if (agentTools && typeof agentTools.initialize === 'function') {
log.info('Initializing agent tools to create vectorSearchTool');
try {
// Force initialization to ensure it runs even if previously marked as initialized
await agentTools.initialize(true);
log.info('Agent tools initialized successfully');
} catch (initError: unknown) {
const errorMessage = initError instanceof Error ? initError.message : String(initError);
log.error(`Failed to initialize agent tools: ${errorMessage}`);
@@ -812,14 +810,11 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
const agentTools = aiServiceManager.getAgentTools();
if (agentTools && typeof agentTools.initialize === 'function') {
await agentTools.initialize(true);
log.info(`Agent tools initialized during preloading`);
}
// Check if the vector search tool is available
const vectorSearchTool = aiServiceManager.getVectorSearchTool();
if (vectorSearchTool && typeof vectorSearchTool.searchNotes === 'function') {
log.info(`Vector search tool successfully preloaded`);
} else {
if (!(vectorSearchTool && typeof vectorSearchTool.searchNotes === 'function')) {
log.error(`Vector search tool not available after initialization`);
}
} catch (error: unknown) {

View File

@@ -300,7 +300,7 @@ export async function initializeDefaultProviders() {
const ollamaBaseUrl = await options.getOption('ollamaBaseUrl');
if (ollamaBaseUrl) {
// Use specific embedding models if available
const embeddingModel = await options.getOption('ollamaEmbeddingModel') || 'nomic-embed-text';
const embeddingModel = await options.getOption('ollamaEmbeddingModel');
try {
// Create provider with initial dimension to be updated during initialization

View File

@@ -63,11 +63,9 @@ async function getOrCreateVectorSearchTool(): Promise<any> {
// Get agent tools manager and initialize it
const agentTools = aiServiceManager.getAgentTools();
if (agentTools && typeof agentTools.initialize === 'function') {
log.info('Initializing agent tools to create vectorSearchTool');
try {
// Force initialization to ensure it runs even if previously marked as initialized
await agentTools.initialize(true);
log.info('Agent tools initialized successfully');
} catch (initError: any) {
log.error(`Failed to initialize agent tools: ${initError.message}`);
return null;
@@ -143,7 +141,7 @@ export class SearchNotesTool implements ToolHandler {
temperature: 0.3,
maxTokens: 200,
// Type assertion to bypass type checking for special internal parameters
...(({
...(({
bypassFormatter: true,
bypassContextProcessing: true
} as Record<string, boolean>))

View File

@@ -13,7 +13,7 @@ import log from '../../log.js';
export class ToolRegistry {
private static instance: ToolRegistry;
private tools: Map<string, ToolHandler> = new Map();
private initializationAttempted: boolean = false;
private initializationAttempted = false;
private constructor() {}
@@ -106,7 +106,6 @@ export class ToolRegistry {
}
this.tools.set(name, handler);
log.info(`Registered tool: ${name}`);
}
/**

View File

@@ -16,5 +16,5 @@ describe("Migration", () => {
resolve();
});
});
});
}, 60_000);
});

View File

@@ -25,10 +25,12 @@ async function migrate() {
}
// backup before attempting migration
await backupService.backupNow(
// creating a special backup for version 0.60.4, the changes in 0.61 are major.
currentDbVersion === 214 ? `before-migration-v060` : "before-migration"
);
if (!process.env.TRILIUM_INTEGRATION_TEST) {
await backupService.backupNow(
// creating a special backup for version 0.60.4, the changes in 0.61 are major.
currentDbVersion === 214 ? `before-migration-v060` : "before-migration"
);
}
const migrations = await prepareMigrations(currentDbVersion);

View File

@@ -76,7 +76,7 @@ function deriveMime(type: string, mime?: string) {
function copyChildAttributes(parentNote: BNote, childNote: BNote) {
for (const attr of parentNote.getAttributes()) {
if (attr.name.startsWith("child:")) {
const name = attr.name.substr(6);
const name = attr.name.substring(6);
const hasAlreadyTemplate = childNote.hasRelation("template");
if (hasAlreadyTemplate && attr.type === "relation" && name === "template") {
@@ -472,7 +472,7 @@ async function downloadImage(noteId: string, imageUrl: string) {
if (imageUrl.toLowerCase().startsWith("file://")) {
imageBuffer = await new Promise((res, rej) => {
const localFilePath = imageUrl.substr("file://".length);
const localFilePath = imageUrl.substring("file://".length);
return fs.readFile(localFilePath, (err, data) => {
if (err) {
@@ -521,14 +521,14 @@ function downloadImages(noteId: string, content: string) {
const inlineImageMatch = /^data:image\/[a-z]+;base64,/.exec(url);
if (inlineImageMatch) {
const imageBase64 = url.substr(inlineImageMatch[0].length);
const imageBase64 = url.substring(inlineImageMatch[0].length);
const imageBuffer = Buffer.from(imageBase64, "base64");
const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, "inline image", true, true);
const encodedTitle = encodeURIComponent(attachment.title);
content = `${content.substr(0, imageMatch.index)}<img src="api/attachments/${attachment.attachmentId}/image/${encodedTitle}"${content.substr(imageMatch.index + imageMatch[0].length)}`;
content = `${content.substring(0, imageMatch.index)}<img src="api/attachments/${attachment.attachmentId}/image/${encodedTitle}"${content.substring(imageMatch.index + imageMatch[0].length)}`;
} else if (
!url.includes("api/images/") &&
!/api\/attachments\/.+\/image\/?.*/.test(url) &&
@@ -631,7 +631,7 @@ function saveAttachments(note: BNote, content: string) {
content: buffer
});
content = `${content.substr(0, attachmentMatch.index)}<a class="reference-link" href="#root/${note.noteId}?viewMode=attachments&attachmentId=${attachment.attachmentId}">${title}</a>${content.substr(attachmentMatch.index + attachmentMatch[0].length)}`;
content = `${content.substring(0, attachmentMatch.index)}<a class="reference-link" href="#root/${note.noteId}?viewMode=attachments&attachmentId=${attachment.attachmentId}">${title}</a>${content.substring(attachmentMatch.index + attachmentMatch[0].length)}`;
}
// removing absolute references to server to keep it working between instances,

View File

@@ -9,6 +9,7 @@ import searchService from "./search/services/search.js";
import SearchContext from "./search/search_context.js";
import hiddenSubtree from "./hidden_subtree.js";
import { t } from "i18next";
import { BNote } from "./backend_script_entrypoint.js";
const { LBTPL_NOTE_LAUNCHER, LBTPL_CUSTOM_WIDGET, LBTPL_SPACER, LBTPL_SCRIPT } = hiddenSubtree;
function getInboxNote(date: string) {
@@ -17,7 +18,7 @@ function getInboxNote(date: string) {
throw new Error("Unable to find workspace note");
}
let inbox;
let inbox: BNote;
if (!workspaceNote.isRoot()) {
inbox = workspaceNote.searchNoteInSubtree("#workspaceInbox");

View File

@@ -203,6 +203,13 @@ function fillInAdditionalProperties(entityChange: EntityChange) {
WHERE attachmentId = ?`,
[entityChange.entityId]
);
} else if (entityChange.entityName === "note_embeddings") {
// Note embeddings are backend-only entities for AI/vector search
// Frontend doesn't need the full embedding data (which is large binary data)
// Just ensure entity is marked as handled - actual sync happens at database level
if (!entityChange.isErased) {
entityChange.entity = { embedId: entityChange.entityId };
}
}
if (entityChange.entity instanceof AbstractBeccaEntity) {

View File

@@ -33,4 +33,13 @@ describe("Share API test", () => {
expect(cannotSetHeadersCount).toBe(0);
});
it("renders custom share template", async () => {
const response = await supertest(app)
.get("/share/pQvNLLoHcMwH")
.expect(200);
expect(cannotSetHeadersCount).toBe(0);
expect(response.text).toContain("Content Start");
expect(response.text).toContain("Content End");
});
});

View File

@@ -16,6 +16,7 @@ import type SBranch from "./shaca/entities/sbranch.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
import options from "../services/options.js";
import { t } from "i18next";
function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } {
if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
@@ -135,7 +136,7 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
}
function register(router: Router) {
function renderNote(note: SNote, req: Request, res: Response) {
async function renderNote(note: SNote, req: Request, res: Response) {
if (!note) {
console.log("Unable to find note ", note);
res.status(404).render("share/404");
@@ -167,7 +168,8 @@ function register(router: Router) {
subRoot,
assetPath: isDev ? assetPath : `../${assetPath}`,
appPath: isDev ? appPath : `../${appPath}`,
showLoginInShareTheme
showLoginInShareTheme,
t
};
let useDefaultView = true;
@@ -182,7 +184,7 @@ function register(router: Router) {
// EJS caches the result of this so we don't need to pre-cache
const includer = (path: string) => {
const childNote = templateNote.children.find((n) => path === n.title);
if (!childNote) throw new Error("Unable to find child note.");
if (!childNote) throw new Error(`Unable to find child note: ${path}.`);
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
const template = childNote.getContent();
@@ -195,11 +197,10 @@ function register(router: Router) {
try {
const content = templateNote.getContent();
if (typeof content === "string") {
import("ejs").then((ejs) => {
const ejsResult = ejs.render(content, opts, { includer });
res.send(ejsResult);
useDefaultView = false; // Rendering went okay, don't use default view
});
const ejs = await import("ejs");
const ejsResult = ejs.render(content, opts, { includer });
res.send(ejsResult);
useDefaultView = false; // Rendering went okay, don't use default view
}
} catch (e: unknown) {
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);

View File

@@ -14,37 +14,35 @@ import type { Express } from "express";
const MINIMUM_NODE_VERSION = "20.0.0";
// setup basic error handling even before requiring dependencies, since those can produce errors as well
export default async function startTriliumServer() {
// setup basic error handling even before requiring dependencies, since those can produce errors as well
process.on("unhandledRejection", (error: Error) => {
// this makes sure that stacktrace of failed promise is printed out
console.log(error);
process.on("unhandledRejection", (error: Error) => {
// this makes sure that stacktrace of failed promise is printed out
console.log(error);
// but also try to log it into file
log.info(error);
});
// but also try to log it into file
log.info(error);
});
function exit() {
console.log("Caught interrupt/termination signal. Exiting.");
process.exit(0);
}
function exit() {
console.log("Caught interrupt/termination signal. Exiting.");
process.exit(0);
}
process.on("SIGINT", exit);
process.on("SIGTERM", exit);
process.on("SIGINT", exit);
process.on("SIGTERM", exit);
if (utils.compareVersions(process.versions.node, MINIMUM_NODE_VERSION) < 0) {
console.error();
console.error(`The Trilium server requires Node.js ${MINIMUM_NODE_VERSION} and later in order to start.\n`);
console.error(`\tCurrent version:\t${process.versions.node}`);
console.error(`\tExpected version:\t${MINIMUM_NODE_VERSION}`);
console.error();
process.exit(1);
}
if (utils.compareVersions(process.versions.node, MINIMUM_NODE_VERSION) < 0) {
console.error();
console.error(`The Trilium server requires Node.js ${MINIMUM_NODE_VERSION} and later in order to start.\n`);
console.error(`\tCurrent version:\t${process.versions.node}`);
console.error(`\tExpected version:\t${MINIMUM_NODE_VERSION}`);
console.error();
process.exit(1);
}
tmp.setGracefulCleanup();
tmp.setGracefulCleanup();
startTrilium();
async function startTrilium() {
const app = await buildApp();
/**
@@ -98,7 +96,7 @@ function startHttpServer(app: Express) {
log.info(`Trusted reverse proxy: ${app.get("trust proxy")}`);
let httpServer;
let httpServer: http.Server | https.Server;
if (config["Network"]["https"]) {
if (!config["Network"]["keyPath"] || !config["Network"]["keyPath"].trim().length) {

View File

@@ -10,8 +10,7 @@ export default defineConfig(() => ({
globals: true,
setupFiles: ["./spec/setup.ts"],
environment: "node",
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
include: ['{src,spec}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,