mirror of
https://github.com/zadam/trilium.git
synced 2026-03-29 08:10:13 +02:00
Compare commits
25 Commits
autocomple
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b551f0fe2d | ||
|
|
f6e8bdb0fd | ||
|
|
9029ea8085 | ||
|
|
d61ade9fe9 | ||
|
|
aa1fe549c7 | ||
|
|
e3701bbcb4 | ||
|
|
fb7fc4bf0c | ||
|
|
dc50ca157d | ||
|
|
ff2e775b5e | ||
|
|
25df43b0be | ||
|
|
1af1fcd148 | ||
|
|
516f9aad45 | ||
|
|
79a420de0f | ||
|
|
ac213b6664 | ||
|
|
ff2d74029a | ||
|
|
31ac1d3f2d | ||
|
|
2c32382ca6 | ||
|
|
9904df1611 | ||
|
|
2d945d4fb2 | ||
|
|
c1f9a22bf3 | ||
|
|
b6435bbfc9 | ||
|
|
63387cb958 | ||
|
|
a8d104ec57 | ||
|
|
10377b527f | ||
|
|
6c295611cc |
@@ -16,7 +16,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.25.1",
|
||||
"@redocly/cli": "2.25.2",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.4",
|
||||
"js-yaml": "4.1.1",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
},
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-js": "1.19.6",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
@@ -29,7 +28,7 @@
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.2",
|
||||
"@preact/signals": "2.9.0",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
@@ -44,7 +43,8 @@
|
||||
"@univerjs/preset-sheets-note": "0.18.0",
|
||||
"@univerjs/preset-sheets-sort": "0.18.0",
|
||||
"@univerjs/presets": "0.18.0",
|
||||
"@zumer/snapdom": "2.5.0",
|
||||
"@zumer/snapdom": "2.6.0",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
@@ -58,7 +58,7 @@
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.43",
|
||||
"katex": "0.16.44",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
@@ -68,7 +68,7 @@
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.4",
|
||||
"preact": "10.29.0",
|
||||
"react-i18next": "16.6.6",
|
||||
"react-i18next": "17.0.0",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "6.0.0",
|
||||
"rrule": "2.8.1",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
|
||||
|
||||
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
|
||||
import bundleService from "../services/bundle.js";
|
||||
import dateNoteService from "../services/date_notes.js";
|
||||
import froca from "../services/froca.js";
|
||||
@@ -198,7 +197,7 @@ export default class Entrypoints extends Component {
|
||||
|
||||
hideAllPopups() {
|
||||
if (utils.isDesktop()) {
|
||||
closeAllHeadlessAutocompletes();
|
||||
$(".aa-input").autocomplete("close");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
|
||||
import froca from "../services/froca.js";
|
||||
import linkService from "../services/link.js";
|
||||
import options from "../services/options.js";
|
||||
import server from "../services/server.js";
|
||||
import SpacedUpdate from "../services/spaced_update.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import Mutex from "../utils/mutex.js";
|
||||
import type { EventData } from "./app_context.js";
|
||||
import appContext from "./app_context.js";
|
||||
import Component from "./component.js";
|
||||
import SpacedUpdate from "../services/spaced_update.js";
|
||||
import server from "../services/server.js";
|
||||
import options from "../services/options.js";
|
||||
import froca from "../services/froca.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import NoteContext from "./note_context.js";
|
||||
import appContext from "./app_context.js";
|
||||
import Mutex from "../utils/mutex.js";
|
||||
import linkService from "../services/link.js";
|
||||
import type { EventData } from "./app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
interface TabState {
|
||||
contexts: NoteContext[];
|
||||
@@ -430,7 +429,10 @@ export default class TabManager extends Component {
|
||||
}
|
||||
|
||||
// close dangling autocompletes after closing the tab
|
||||
closeAllHeadlessAutocompletes();
|
||||
const $autocompleteEl = $(".aa-input");
|
||||
if ("autocomplete" in $autocompleteEl) {
|
||||
$autocompleteEl.autocomplete("close");
|
||||
}
|
||||
|
||||
// close dangling tooltips
|
||||
$("body > div.tooltip").remove();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const RELATION = "relation";
|
||||
* end user. Those types should be used only for checking against, they are
|
||||
* not for direct use.
|
||||
*/
|
||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
|
||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet" | "llmChat";
|
||||
|
||||
export interface NotePathRecord {
|
||||
isArchived: boolean;
|
||||
|
||||
@@ -16,6 +16,17 @@ async function initJQuery() {
|
||||
const $ = (await import("jquery")).default;
|
||||
window.$ = $;
|
||||
window.jQuery = $;
|
||||
|
||||
// Polyfill removed jQuery methods for autocomplete.js compatibility
|
||||
($ as any).isArray = Array.isArray;
|
||||
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
|
||||
($ as any).isPlainObject = function(obj: any) {
|
||||
if (obj == null || typeof obj !== 'object') { return false; }
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
if (proto === null) { return true; }
|
||||
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
|
||||
return typeof Ctor === 'function' && Ctor === Object;
|
||||
};
|
||||
}
|
||||
|
||||
async function setupGlob() {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import appContext from "./components/app_context.js";
|
||||
import glob from "./services/glob.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
|
||||
@@ -8,6 +8,17 @@ async function loadBootstrap() {
|
||||
}
|
||||
}
|
||||
|
||||
// Polyfill removed jQuery methods for autocomplete.js compatibility
|
||||
($ as any).isArray = Array.isArray;
|
||||
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
|
||||
($ as any).isPlainObject = function(obj: any) {
|
||||
if (obj == null || typeof obj !== 'object') { return false; }
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
if (proto === null) { return true; }
|
||||
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
|
||||
return typeof Ctor === 'function' && Ctor === Object;
|
||||
};
|
||||
|
||||
(window as any).$ = $;
|
||||
(window as any).jQuery = $;
|
||||
await loadBootstrap();
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldAutocompleteHandleEnterKey } from "./attribute_autocomplete.js";
|
||||
|
||||
describe("attribute autocomplete enter handling", () => {
|
||||
it("delegates plain Enter when the panel is open and an item is active", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: false, metaKey: false },
|
||||
{ isPanelOpen: true, hasActiveItem: true }
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not delegate plain Enter when there is no active suggestion", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: false, metaKey: false },
|
||||
{ isPanelOpen: true, hasActiveItem: false }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not delegate plain Enter when the panel is closed", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: false, metaKey: false },
|
||||
{ isPanelOpen: false, hasActiveItem: false }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not delegate Ctrl+Enter even when an item is active", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: true, metaKey: false },
|
||||
{ isPanelOpen: true, hasActiveItem: true }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not delegate Cmd+Enter even when an item is active", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "Enter", ctrlKey: false, metaKey: true },
|
||||
{ isPanelOpen: true, hasActiveItem: true }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores non-Enter keys", () => {
|
||||
expect(shouldAutocompleteHandleEnterKey(
|
||||
{ key: "ArrowDown", ctrlKey: false, metaKey: false },
|
||||
{ isPanelOpen: false, hasActiveItem: false }
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,450 +1,114 @@
|
||||
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
|
||||
import { createAutocomplete } from "@algolia/autocomplete-core";
|
||||
|
||||
import type { AttributeType } from "../entities/fattribute.js";
|
||||
import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js";
|
||||
import server from "./server.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NameItem extends BaseItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function shouldAutocompleteHandleEnterKey(
|
||||
event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey">,
|
||||
{ isPanelOpen, hasActiveItem }: { isPanelOpen: boolean; hasActiveItem: boolean }
|
||||
) {
|
||||
if (event.key !== "Enter") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isPanelOpen && hasActiveItem;
|
||||
}
|
||||
|
||||
interface InitAttributeNameOptions {
|
||||
/** The <input> element where the user types */
|
||||
interface InitOptions {
|
||||
$el: JQuery<HTMLElement>;
|
||||
attributeType?: AttributeType | (() => AttributeType);
|
||||
open: boolean;
|
||||
/** Called when the user selects a value or the panel closes */
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ManagedInstance {
|
||||
autocomplete: CoreAutocompleteApi<NameItem>;
|
||||
panelEl: HTMLElement;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
|
||||
|
||||
function renderItems(
|
||||
panelEl: HTMLElement,
|
||||
items: NameItem[],
|
||||
activeItemId: number | null,
|
||||
onSelect: (item: NameItem) => void,
|
||||
onActivate: (index: number) => void,
|
||||
onDeactivate: () => void
|
||||
): void {
|
||||
panelEl.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
panelEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const list = document.createElement("ul");
|
||||
list.className = "aa-core-list";
|
||||
items.forEach((item, index) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "aa-core-item";
|
||||
if (index === activeItemId) {
|
||||
li.classList.add("aa-core-item--active");
|
||||
}
|
||||
li.textContent = item.name;
|
||||
li.addEventListener("mousemove", () => {
|
||||
if (activeItemId === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
onActivate(index);
|
||||
});
|
||||
li.addEventListener("mouseleave", (event) => {
|
||||
const relatedTarget = event.relatedTarget;
|
||||
if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onDeactivate();
|
||||
});
|
||||
li.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); // prevent input blur
|
||||
e.stopPropagation();
|
||||
onSelect(item);
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
panelEl.appendChild(list);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute name autocomplete — new (autocomplete-core, headless)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
||||
autocomplete.setQuery(inputEl.value || "");
|
||||
};
|
||||
|
||||
// Already initialized — just open if requested
|
||||
if (instanceMap.has(inputEl)) {
|
||||
if (open) {
|
||||
const inst = instanceMap.get(inputEl)!;
|
||||
syncQueryFromInputValue(inst.autocomplete);
|
||||
inst.autocomplete.setIsOpen(true);
|
||||
inst.autocomplete.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const panelController = createHeadlessPanelController({ inputEl });
|
||||
const { panelEl } = panelController;
|
||||
|
||||
let isPanelOpen = false;
|
||||
let hasActiveItem = false;
|
||||
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
defaultActiveItemId: 0,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
},
|
||||
|
||||
getSources({ query }) {
|
||||
return [
|
||||
withHeadlessSourceDefaults({
|
||||
sourceId: "attribute-names",
|
||||
getItems() {
|
||||
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
||||
return server
|
||||
.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`)
|
||||
.then((names) => names.map((name) => ({ name })));
|
||||
},
|
||||
getItemInputValue({ item }) {
|
||||
return item.name;
|
||||
},
|
||||
onSelect({ item }) {
|
||||
inputEl.value = item.name;
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
onStateChange({ state }) {
|
||||
isPanelOpen = state.isOpen;
|
||||
hasActiveItem = state.activeItemId !== null;
|
||||
|
||||
// Render items
|
||||
const collections = state.collections;
|
||||
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
||||
const activeId = state.activeItemId ?? null;
|
||||
|
||||
if (state.isOpen && items.length > 0) {
|
||||
renderItems(
|
||||
panelEl,
|
||||
items,
|
||||
activeId,
|
||||
(item) => {
|
||||
inputEl.value = item.name;
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
},
|
||||
(index) => {
|
||||
autocomplete.setActiveItemId(index);
|
||||
},
|
||||
() => {
|
||||
autocomplete.setActiveItemId(null);
|
||||
}
|
||||
);
|
||||
panelController.startPositioning();
|
||||
} else {
|
||||
panelController.hide();
|
||||
}
|
||||
|
||||
if (!state.isOpen) {
|
||||
panelController.hide();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelController.hide();
|
||||
});
|
||||
|
||||
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
|
||||
inputEl,
|
||||
autocomplete,
|
||||
onInput(e, handlers) {
|
||||
handlers.onChange(e as any);
|
||||
},
|
||||
onFocus(e, handlers) {
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
handlers.onFocus(e as any);
|
||||
},
|
||||
onBlur() {
|
||||
// Delay to allow mousedown on panel items
|
||||
setTimeout(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelController.hide();
|
||||
onValueChange?.(inputEl.value);
|
||||
}, 50);
|
||||
},
|
||||
onKeyDown(e, handlers) {
|
||||
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
// Prevent the enter key from propagating to parent dialogs
|
||||
// (which might interpret it as "submit" or "save and close")
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e as any);
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
unregisterGlobalCloser();
|
||||
cleanupInputBindings();
|
||||
panelController.destroy();
|
||||
};
|
||||
|
||||
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
||||
|
||||
if (open) {
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
panelController.startPositioning();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label value autocomplete (headless autocomplete-core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LabelValueInitOptions {
|
||||
$el: JQuery<HTMLElement>;
|
||||
open: boolean;
|
||||
nameCallback?: () => string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
||||
autocomplete.setQuery(inputEl.value || "");
|
||||
};
|
||||
/**
|
||||
* @param $el - element on which to init autocomplete
|
||||
* @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
|
||||
* @param open - should the autocomplete be opened after init?
|
||||
*/
|
||||
function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) {
|
||||
if (!$el.hasClass("aa-input")) {
|
||||
$el.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
openOnFocus: true,
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "name",
|
||||
// disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type
|
||||
cache: false,
|
||||
source: async (term, cb) => {
|
||||
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
||||
|
||||
if (instanceMap.has(inputEl)) {
|
||||
if (open) {
|
||||
const inst = instanceMap.get(inputEl)!;
|
||||
syncQueryFromInputValue(inst.autocomplete);
|
||||
inst.autocomplete.setIsOpen(true);
|
||||
inst.autocomplete.refresh();
|
||||
}
|
||||
const names = await server.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
|
||||
const result = names.map((name) => ({ name }));
|
||||
|
||||
cb(result);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$el.on("autocomplete:opened", () => {
|
||||
if ($el.attr("readonly")) {
|
||||
$el.autocomplete("close");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (open) {
|
||||
$el.autocomplete("open");
|
||||
}
|
||||
}
|
||||
|
||||
async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
|
||||
if ($el.hasClass("aa-input")) {
|
||||
// we reinit every time because autocomplete seems to have a bug where it retains state from last
|
||||
// open even though the value was reset
|
||||
$el.autocomplete("destroy");
|
||||
}
|
||||
|
||||
let attributeName = "";
|
||||
if (nameCallback) {
|
||||
attributeName = nameCallback();
|
||||
}
|
||||
|
||||
if (attributeName.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelController = createHeadlessPanelController({ inputEl });
|
||||
const { panelEl } = panelController;
|
||||
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
|
||||
|
||||
let isPanelOpen = false;
|
||||
let hasActiveItem = false;
|
||||
let isSelecting = false;
|
||||
if (attributeValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cachedAttributeName = "";
|
||||
let cachedAttributeValues: NameItem[] = [];
|
||||
|
||||
const handleSelect = (item: NameItem) => {
|
||||
isSelecting = true;
|
||||
inputEl.value = item.name;
|
||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
isSelecting = false;
|
||||
|
||||
setTimeout(() => {
|
||||
// Preserve the legacy contract: several consumers still commit the
|
||||
// selected value from their existing Enter key handlers instead of
|
||||
// listening to the autocomplete selection event directly.
|
||||
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
defaultActiveItemId: null,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
$el.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
openOnFocus: false, // handled manually
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "value",
|
||||
cache: false,
|
||||
source: async function (term, cb) {
|
||||
term = term.toLowerCase();
|
||||
|
||||
getSources({ query }) {
|
||||
return [
|
||||
withHeadlessSourceDefaults({
|
||||
sourceId: "attribute-values",
|
||||
async getItems() {
|
||||
const attributeName = nameCallback ? nameCallback() : "";
|
||||
if (!attributeName.trim()) {
|
||||
return [];
|
||||
}
|
||||
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
|
||||
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
|
||||
cachedAttributeName = attributeName;
|
||||
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
|
||||
cachedAttributeValues = values.map((name) => ({ name }));
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
|
||||
},
|
||||
getItemInputValue({ item }) {
|
||||
return item.name;
|
||||
},
|
||||
onSelect({ item }) {
|
||||
handleSelect(item);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
onStateChange({ state }) {
|
||||
isPanelOpen = state.isOpen;
|
||||
hasActiveItem = state.activeItemId !== null;
|
||||
|
||||
const collections = state.collections;
|
||||
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
||||
const activeId = state.activeItemId ?? null;
|
||||
|
||||
if (state.isOpen && items.length > 0) {
|
||||
renderItems(
|
||||
panelEl,
|
||||
items,
|
||||
activeId,
|
||||
handleSelect,
|
||||
(index) => {
|
||||
autocomplete.setActiveItemId(index);
|
||||
},
|
||||
() => {
|
||||
autocomplete.setActiveItemId(null);
|
||||
}
|
||||
);
|
||||
panelController.startPositioning();
|
||||
} else {
|
||||
panelController.hide();
|
||||
cb(filtered);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
if (!state.isOpen) {
|
||||
panelController.hide();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelController.hide();
|
||||
});
|
||||
|
||||
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
|
||||
inputEl,
|
||||
autocomplete,
|
||||
onInput(e, handlers) {
|
||||
if (!isSelecting) {
|
||||
handlers.onChange(e as any);
|
||||
}
|
||||
},
|
||||
onFocus(e, handlers) {
|
||||
const attributeName = nameCallback ? nameCallback() : "";
|
||||
if (attributeName !== cachedAttributeName) {
|
||||
cachedAttributeName = "";
|
||||
cachedAttributeValues = [];
|
||||
}
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
handlers.onFocus(e as any);
|
||||
},
|
||||
onBlur() {
|
||||
setTimeout(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelController.hide();
|
||||
onValueChange?.(inputEl.value);
|
||||
}, 50);
|
||||
},
|
||||
onKeyDown(e, handlers) {
|
||||
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e as any);
|
||||
$el.on("autocomplete:opened", () => {
|
||||
if ($el.attr("readonly")) {
|
||||
$el.autocomplete("close");
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
unregisterGlobalCloser();
|
||||
cleanupInputBindings();
|
||||
panelController.destroy();
|
||||
};
|
||||
|
||||
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
||||
|
||||
if (open) {
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
panelController.startPositioning();
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyAutocomplete($el: JQuery<HTMLElement> | HTMLElement) {
|
||||
const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
|
||||
const instance = instanceMap.get(inputEl);
|
||||
if (instance) {
|
||||
instance.cleanup();
|
||||
instanceMap.delete(inputEl);
|
||||
$el.autocomplete("open");
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
initAttributeNameAutocomplete,
|
||||
destroyAutocomplete,
|
||||
initLabelValueAutocomplete,
|
||||
initLabelValueAutocomplete
|
||||
};
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import $ from "jquery";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
showSpy,
|
||||
hideSpy,
|
||||
updateDisplayedShortcutsSpy,
|
||||
saveFocusedElementSpy,
|
||||
focusSavedElementSpy
|
||||
} = vi.hoisted(() => ({
|
||||
showSpy: vi.fn(),
|
||||
hideSpy: vi.fn(),
|
||||
updateDisplayedShortcutsSpy: vi.fn(),
|
||||
saveFocusedElementSpy: vi.fn(),
|
||||
focusSavedElementSpy: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("bootstrap", () => ({
|
||||
Modal: {
|
||||
getOrCreateInstance: vi.fn(() => ({
|
||||
show: showSpy,
|
||||
hide: hideSpy
|
||||
}))
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("./keyboard_actions.js", () => ({
|
||||
default: {
|
||||
updateDisplayedShortcuts: updateDisplayedShortcutsSpy
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("./focus.js", () => ({
|
||||
saveFocusedElement: saveFocusedElementSpy,
|
||||
focusSavedElement: focusSavedElementSpy
|
||||
}));
|
||||
|
||||
import { closeAllHeadlessAutocompletes, registerHeadlessAutocompleteCloser } from "./autocomplete_core.js";
|
||||
import { openDialog } from "./dialog.js";
|
||||
|
||||
describe("headless autocomplete closing", () => {
|
||||
const unregisterClosers: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(window as any).glob = {
|
||||
...(window as any).glob,
|
||||
activeDialog: null
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (unregisterClosers.length > 0) {
|
||||
unregisterClosers.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes every registered closer and skips unregistered ones", () => {
|
||||
const closer1 = vi.fn();
|
||||
const closer2 = vi.fn();
|
||||
const closer3 = vi.fn();
|
||||
|
||||
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer1));
|
||||
const unregister2 = registerHeadlessAutocompleteCloser(closer2);
|
||||
unregisterClosers.push(unregister2);
|
||||
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer3));
|
||||
|
||||
unregister2();
|
||||
|
||||
closeAllHeadlessAutocompletes();
|
||||
|
||||
expect(closer1).toHaveBeenCalledTimes(1);
|
||||
expect(closer2).not.toHaveBeenCalled();
|
||||
expect(closer3).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes registered autocompletes when a dialog finishes hiding", async () => {
|
||||
const closer = vi.fn();
|
||||
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer));
|
||||
|
||||
const dialogEl = document.createElement("div");
|
||||
const $dialog = $(dialogEl);
|
||||
|
||||
await openDialog($dialog, false);
|
||||
$dialog.trigger("hidden.bs.modal");
|
||||
|
||||
expect(showSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateDisplayedShortcutsSpy).toHaveBeenCalledWith($dialog);
|
||||
expect(saveFocusedElementSpy).toHaveBeenCalledTimes(1);
|
||||
expect(closer).toHaveBeenCalledTimes(1);
|
||||
expect(focusSavedElementSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
import type { AutocompleteApi, AutocompleteSource, BaseItem } from "@algolia/autocomplete-core";
|
||||
|
||||
export const HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR = ".aa-core-panel";
|
||||
|
||||
type HeadlessSourceDefaults = Required<Pick<AutocompleteSource<any>, "getItemUrl" | "onActive" | "onResolve">>;
|
||||
|
||||
const headlessAutocompleteClosers = new Set<() => void>();
|
||||
|
||||
export function withHeadlessSourceDefaults<TSource extends AutocompleteSource<any>>(
|
||||
source: TSource
|
||||
): TSource & HeadlessSourceDefaults {
|
||||
return {
|
||||
getItemUrl() {
|
||||
return undefined;
|
||||
},
|
||||
onActive() {
|
||||
// Headless consumers handle highlight side effects themselves.
|
||||
},
|
||||
onResolve() {
|
||||
// Headless consumers resolve and render items manually.
|
||||
},
|
||||
...source
|
||||
} as TSource & HeadlessSourceDefaults;
|
||||
}
|
||||
|
||||
export function registerHeadlessAutocompleteCloser(close: () => void) {
|
||||
headlessAutocompleteClosers.add(close);
|
||||
|
||||
return () => {
|
||||
headlessAutocompleteClosers.delete(close);
|
||||
};
|
||||
}
|
||||
|
||||
export function closeAllHeadlessAutocompletes() {
|
||||
for (const close of Array.from(headlessAutocompleteClosers)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
interface HeadlessPanelControllerOptions {
|
||||
inputEl: HTMLElement;
|
||||
container?: HTMLElement | null;
|
||||
className?: string;
|
||||
containedClassName?: string;
|
||||
}
|
||||
|
||||
export function createHeadlessPanelController({
|
||||
inputEl,
|
||||
container,
|
||||
className = "aa-core-panel",
|
||||
containedClassName = "aa-core-panel--contained"
|
||||
}: HeadlessPanelControllerOptions) {
|
||||
const panelEl = document.createElement("div");
|
||||
panelEl.className = className;
|
||||
|
||||
const isContained = Boolean(container);
|
||||
if (isContained) {
|
||||
panelEl.classList.add(containedClassName);
|
||||
container!.appendChild(panelEl);
|
||||
} else {
|
||||
document.body.appendChild(panelEl);
|
||||
}
|
||||
|
||||
panelEl.style.display = "none";
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const positionPanel = () => {
|
||||
if (isContained) {
|
||||
panelEl.style.position = "static";
|
||||
panelEl.style.top = "";
|
||||
panelEl.style.left = "";
|
||||
panelEl.style.width = "100%";
|
||||
panelEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
panelEl.style.position = "fixed";
|
||||
panelEl.style.top = `${rect.bottom}px`;
|
||||
panelEl.style.left = `${rect.left}px`;
|
||||
panelEl.style.width = `${rect.width}px`;
|
||||
panelEl.style.display = "block";
|
||||
};
|
||||
|
||||
const stopPositioning = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPositioning = () => {
|
||||
if (isContained) {
|
||||
positionPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (rafId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
positionPanel();
|
||||
rafId = requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
update();
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
hide();
|
||||
panelEl.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
panelEl,
|
||||
hide,
|
||||
destroy,
|
||||
startPositioning,
|
||||
stopPositioning
|
||||
};
|
||||
}
|
||||
|
||||
type InputHandlers<TItem extends BaseItem> = ReturnType<AutocompleteApi<TItem>["getInputProps"]>;
|
||||
|
||||
interface InputBinding<TEvent extends Event = Event> {
|
||||
type: string;
|
||||
listener: (event: TEvent) => void;
|
||||
}
|
||||
|
||||
interface BindAutocompleteInputOptions<TItem extends BaseItem> {
|
||||
inputEl: HTMLInputElement;
|
||||
autocomplete: AutocompleteApi<TItem>;
|
||||
onInput?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onFocus?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onBlur?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers<TItem>) => void;
|
||||
extraBindings?: InputBinding[];
|
||||
}
|
||||
|
||||
export function bindAutocompleteInput<TItem extends BaseItem>({
|
||||
inputEl,
|
||||
autocomplete,
|
||||
onInput,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
extraBindings = []
|
||||
}: BindAutocompleteInputOptions<TItem>) {
|
||||
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
|
||||
|
||||
const bindings: InputBinding[] = [
|
||||
{
|
||||
type: "input",
|
||||
listener: (event: Event) => {
|
||||
onInput?.(event, handlers);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "focus",
|
||||
listener: (event: Event) => {
|
||||
onFocus?.(event, handlers);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "blur",
|
||||
listener: (event: Event) => {
|
||||
onBlur?.(event, handlers);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "keydown",
|
||||
listener: (event: Event) => {
|
||||
onKeyDown?.(event as KeyboardEvent, handlers);
|
||||
}
|
||||
},
|
||||
...extraBindings
|
||||
];
|
||||
|
||||
bindings.forEach(({ type, listener }) => {
|
||||
inputEl.addEventListener(type, listener as EventListener);
|
||||
});
|
||||
|
||||
return () => {
|
||||
bindings.forEach(({ type, listener }) => {
|
||||
inputEl.removeEventListener(type, listener as EventListener);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
|
||||
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import { closeAllHeadlessAutocompletes } from "./autocomplete_core.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
||||
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
||||
|
||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
||||
if (closeActDialog) {
|
||||
@@ -17,7 +15,10 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
|
||||
Modal.getOrCreateInstance($dialog[0], config).show();
|
||||
|
||||
$dialog.on("hidden.bs.modal", () => {
|
||||
closeAllHeadlessAutocompletes();
|
||||
const $autocompleteEl = $(".aa-input");
|
||||
if ("autocomplete" in $autocompleteEl) {
|
||||
$autocompleteEl.autocomplete("close");
|
||||
}
|
||||
|
||||
if (!glob.activeDialog || glob.activeDialog === $dialog) {
|
||||
focusSavedElement();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type Component from "../components/component.js";
|
||||
import server from "./server.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
||||
import type Component from "../components/component.js";
|
||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
|
||||
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
|
||||
|
||||
@@ -52,10 +51,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
||||
getActionsForScope("window").then((actions) => {
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
shortcutService.bindGlobalShortcut(shortcut, () => {
|
||||
const ntxId = appContext.tabManager?.activeNtxId ?? null;
|
||||
appContext.triggerCommand(action.actionName, { ntxId });
|
||||
});
|
||||
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
105
apps/client/src/services/llm_chat.ts
Normal file
105
apps/client/src/services/llm_chat.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import server from "./server.js";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatConfig {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
enableWebSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface StreamCallbacks {
|
||||
onChunk: (text: string) => void;
|
||||
onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
|
||||
onToolResult?: (toolName: string, result: string) => void;
|
||||
onCitation?: (citation: Citation) => void;
|
||||
onError: (error: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a chat completion from the LLM API using Server-Sent Events.
|
||||
*/
|
||||
export async function streamChatCompletion(
|
||||
messages: ChatMessage[],
|
||||
config: ChatConfig,
|
||||
callbacks: StreamCallbacks
|
||||
): Promise<void> {
|
||||
const headers = await server.getHeaders();
|
||||
|
||||
const response = await fetch(`${window.glob.baseApiUrl}llm-chat/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json"
|
||||
} as HeadersInit,
|
||||
body: JSON.stringify({ messages, config })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
callbacks.onError(`HTTP ${response.status}: ${response.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
callbacks.onError("No response body");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
switch (data.type) {
|
||||
case "text":
|
||||
callbacks.onChunk(data.content);
|
||||
break;
|
||||
case "tool_use":
|
||||
callbacks.onToolUse?.(data.toolName, data.toolInput);
|
||||
break;
|
||||
case "tool_result":
|
||||
callbacks.onToolResult?.(data.toolName, data.result);
|
||||
break;
|
||||
case "citation":
|
||||
callbacks.onCitation?.({ url: data.url, title: data.title });
|
||||
break;
|
||||
case "error":
|
||||
callbacks.onError(data.error);
|
||||
break;
|
||||
case "done":
|
||||
callbacks.onDone();
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors for partial data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
|
||||
|
||||
// Misc note types
|
||||
{ type: "llmChat", mime: "application/json", title: t("note_types.llm-chat"), icon: "bx-message-square-dots" },
|
||||
{ type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
|
||||
{ type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
|
||||
{ type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },
|
||||
|
||||
@@ -892,6 +892,33 @@ table.promoted-attributes-in-tooltip th {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.algolia-autocomplete {
|
||||
width: calc(100% - 30px);
|
||||
z-index: 2000 !important;
|
||||
}
|
||||
|
||||
.algolia-autocomplete-container .aa-dropdown-menu {
|
||||
position: inherit !important;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.algolia-autocomplete .aa-input,
|
||||
.algolia-autocomplete .aa-hint {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.algolia-autocomplete .aa-dropdown-menu {
|
||||
width: 100%;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-top: none;
|
||||
z-index: 2000 !important;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu .aa-suggestion {
|
||||
cursor: pointer;
|
||||
padding: 6px 16px;
|
||||
@@ -933,153 +960,6 @@ table.promoted-attributes-in-tooltip th {
|
||||
background-color: var(--active-item-background-color);
|
||||
}
|
||||
|
||||
/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */
|
||||
|
||||
.aa-core-panel {
|
||||
z-index: 10000;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-top: none;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.aa-core-panel.aa-dropdown-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aa-core-panel--contained {
|
||||
position: static !important;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.aa-core-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-core-item {
|
||||
cursor: pointer;
|
||||
padding: 7px 16px;
|
||||
margin: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.aa-core-item--active {
|
||||
color: var(--active-item-text-color);
|
||||
background-color: var(--active-item-background-color);
|
||||
}
|
||||
|
||||
.aa-core-item .note-suggestion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aa-core-item .icon,
|
||||
.aa-core-item .command-icon {
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.aa-core-item .text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-primary-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
font-size: 1.02em;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-attributes {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.65;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-attributes {
|
||||
padding-inline-start: 14px;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-shortcut,
|
||||
.aa-core-item kbd.command-shortcut {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-text-color);
|
||||
font-family: inherit !important;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.aa-core-item .command-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.aa-core-item .command-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aa-core-item .command-name {
|
||||
font-weight: bold;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.aa-core-item .command-description {
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-title b,
|
||||
.aa-core-item .search-result-path b,
|
||||
.aa-core-item .search-result-attributes b,
|
||||
.aa-core-item .command-name b,
|
||||
.aa-core-item .command-description b {
|
||||
color: var(--admonition-warning-accent-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-separator {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-core-panel--contained {
|
||||
max-height: calc(80vh - 200px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.help-button {
|
||||
float: inline-end;
|
||||
background: none;
|
||||
|
||||
@@ -128,8 +128,8 @@
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* The headless autocomplete panel rendered into the empty-note results container */
|
||||
.note-detail-empty .aa-core-panel--contained {
|
||||
/* The search results list */
|
||||
.note-detail-empty span.aa-dropdown-menu {
|
||||
margin-top: 1em;
|
||||
border: unset;
|
||||
}
|
||||
|
||||
@@ -1599,6 +1599,7 @@
|
||||
"geo-map": "Geo Map",
|
||||
"beta-feature": "Beta",
|
||||
"ai-chat": "AI Chat",
|
||||
"llm-chat": "AI Chat",
|
||||
"task-list": "Task List",
|
||||
"new-feature": "New",
|
||||
"collections": "Collections",
|
||||
@@ -1610,6 +1611,15 @@
|
||||
"toggle-on-hint": "Note is not protected, click to make it protected",
|
||||
"toggle-off-hint": "Note is protected, click to make it unprotected"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "Type a message...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"empty_state": "Start a conversation by typing a message below.",
|
||||
"searching_web": "Searching the web...",
|
||||
"web_search": "Web search",
|
||||
"sources": "Sources"
|
||||
},
|
||||
"shared_switch": {
|
||||
"shared": "Shared",
|
||||
"toggle-on-title": "Share the note",
|
||||
|
||||
28
apps/client/src/types.d.ts
vendored
28
apps/client/src/types.d.ts
vendored
@@ -6,6 +6,7 @@ import type { PrintReport } from "./print";
|
||||
import type { lint } from "./services/eslint";
|
||||
import type { Froca } from "./services/froca-interface";
|
||||
import { Library } from "./services/library_loader";
|
||||
import { Suggestion } from "./services/note_autocomplete";
|
||||
import server from "./services/server";
|
||||
import utils from "./services/utils";
|
||||
|
||||
@@ -82,7 +83,34 @@ declare global {
|
||||
"note-load-progress": CustomEvent<{ progress: number }>;
|
||||
}
|
||||
|
||||
interface AutoCompleteConfig {
|
||||
appendTo?: HTMLElement | null;
|
||||
hint?: boolean;
|
||||
openOnFocus?: boolean;
|
||||
minLength?: number;
|
||||
tabAutocomplete?: boolean;
|
||||
autoselect?: boolean;
|
||||
dropdownMenuContainer?: HTMLElement;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
type AutoCompleteCallback = (values: AutoCompleteArg[]) => void;
|
||||
|
||||
interface AutoCompleteArg {
|
||||
name?: string;
|
||||
value?: string;
|
||||
notePathTitle?: string;
|
||||
displayKey?: "name" | "value" | "notePathTitle";
|
||||
cache?: boolean;
|
||||
source?: (term: string, cb: AutoCompleteCallback) => void,
|
||||
templates?: {
|
||||
suggestion: (suggestion: Suggestion) => string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface JQuery {
|
||||
autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery<HTMLElement>;
|
||||
|
||||
getSelectedNotePath(): string | undefined;
|
||||
getSelectedNoteId(): string | null;
|
||||
setSelectedNotePath(notePath: string | null | undefined);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from
|
||||
import NoteContext from "../components/note_context";
|
||||
import FAttribute from "../entities/fattribute";
|
||||
import FNote from "../entities/fnote";
|
||||
import attributeAutocompleteService from "../services/attribute_autocomplete";
|
||||
import { Attribute } from "../services/attribute_parser";
|
||||
import attributes from "../services/attributes";
|
||||
import { t } from "../services/i18n";
|
||||
@@ -37,7 +36,8 @@ interface CellProps {
|
||||
setCellToFocus(cell: Cell): void;
|
||||
}
|
||||
|
||||
type OnChangeEventData = TargetedEvent<HTMLInputElement | HTMLTextAreaElement, Event> | InputEvent;
|
||||
type OnChangeEventData = TargetedEvent<HTMLInputElement | HTMLTextAreaElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
|
||||
type OnChangeListener = (e: OnChangeEventData) => void | Promise<void>;
|
||||
|
||||
export default function PromotedAttributes() {
|
||||
const { note, componentId, noteContext } = useNoteContext();
|
||||
@@ -201,9 +201,10 @@ function LabelInput(props: CellProps & { inputId: string }) {
|
||||
}, [ cell, componentId, note, setCells ]);
|
||||
const extraInputProps: InputHTMLAttributes = {};
|
||||
|
||||
useTextLabelAutocomplete(inputId, valueAttr, definition, async (value) => {
|
||||
setDraft(value);
|
||||
await updateAttribute(note, cell, componentId, value, setCells);
|
||||
useTextLabelAutocomplete(inputId, valueAttr, definition, (e) => {
|
||||
if (e.currentTarget instanceof HTMLInputElement) {
|
||||
setDraft(e.currentTarget.value);
|
||||
}
|
||||
});
|
||||
|
||||
// React to model changes.
|
||||
@@ -259,7 +260,7 @@ function LabelInput(props: CellProps & { inputId: string }) {
|
||||
className="open-external-link-button"
|
||||
icon="bx bx-window-open"
|
||||
title={t("promoted_attributes.open_external_link")}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
|
||||
const url = inputEl?.value;
|
||||
if (url) {
|
||||
@@ -414,31 +415,55 @@ function InputButton({ icon, className, title, onClick }: {
|
||||
);
|
||||
}
|
||||
|
||||
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onValueChange: (value: string) => void) {
|
||||
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onChangeListener: OnChangeListener) {
|
||||
const [ attributeValues, setAttributeValues ] = useState<{ value: string }[] | null>(null);
|
||||
|
||||
// Obtain data.
|
||||
useEffect(() => {
|
||||
if (definition.labelType !== "text") {
|
||||
return;
|
||||
}
|
||||
|
||||
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributesValues) => {
|
||||
setAttributeValues(_attributesValues.map((attribute) => ({ value: attribute })));
|
||||
});
|
||||
}, [ definition.labelType, valueAttr.name ]);
|
||||
|
||||
// Initialize autocomplete.
|
||||
useEffect(() => {
|
||||
if (attributeValues?.length === 0) return;
|
||||
const el = document.getElementById(inputId) as HTMLInputElement | null;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (!el) return;
|
||||
|
||||
const $input = $(el);
|
||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
||||
$el: $input,
|
||||
open: false,
|
||||
nameCallback: () => valueAttr.name,
|
||||
onValueChange: (value) => {
|
||||
onValueChange(value);
|
||||
}
|
||||
});
|
||||
$input.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
autoselect: false,
|
||||
openOnFocus: true,
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "value",
|
||||
source (term, cb) {
|
||||
term = term.toLowerCase();
|
||||
|
||||
return () => {
|
||||
attributeAutocompleteService.destroyAutocomplete($input);
|
||||
};
|
||||
}, [ definition.labelType, inputId, onValueChange, valueAttr.name ]);
|
||||
const filtered = (attributeValues ?? []).filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
|
||||
cb(filtered);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$input.off("autocomplete:selected");
|
||||
$input.on("autocomplete:selected", onChangeListener);
|
||||
|
||||
return () => $input.autocomplete("destroy");
|
||||
}, [ inputId, attributeValues, onChangeListener ]);
|
||||
}
|
||||
|
||||
async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch<StateUpdater<Cell[] | undefined>>) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import appContext from "../../components/app_context.js";
|
||||
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import { HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR } from "../../services/autocomplete_core.js";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
|
||||
import froca from "../../services/froca.js";
|
||||
@@ -376,13 +375,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
});
|
||||
this.$inputName.on("change", () => this.userEditedAttribute());
|
||||
this.$inputName.on("autocomplete:closed", () => this.userEditedAttribute());
|
||||
|
||||
this.$inputName.on("focus", () => {
|
||||
attributeAutocompleteService.initAttributeNameAutocomplete({
|
||||
$el: this.$inputName,
|
||||
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
|
||||
open: true,
|
||||
onValueChange: () => this.userEditedAttribute(),
|
||||
open: true
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,12 +394,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
});
|
||||
this.$inputValue.on("change", () => this.userEditedAttribute());
|
||||
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
|
||||
this.$inputValue.on("focus", () => {
|
||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
||||
$el: this.$inputValue,
|
||||
open: true,
|
||||
nameCallback: () => String(this.$inputName.val()),
|
||||
onValueChange: () => this.userEditedAttribute(),
|
||||
nameCallback: () => String(this.$inputName.val())
|
||||
});
|
||||
});
|
||||
|
||||
@@ -481,9 +480,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes");
|
||||
|
||||
$(window).on("mousedown", (e) => {
|
||||
if (!$(e.target).closest(this.$widget[0]).length
|
||||
&& !$(e.target).closest(HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR).length
|
||||
&& !$(e.target).closest("#context-menu-container").length) {
|
||||
if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { FunctionComponent } from "preact";
|
||||
import { render } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type AddLinkDialogTestState,createAddLinkDialogTestState, setupAddLinkDialogMocks } from "./add_link.spec_utils";
|
||||
|
||||
describe("AddLinkDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let AddLinkDialog: FunctionComponent;
|
||||
let state: AddLinkDialogTestState;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
state = createAddLinkDialogTestState();
|
||||
vi.clearAllMocks();
|
||||
setupAddLinkDialogMocks(state);
|
||||
|
||||
({ default: AddLinkDialog } = await import("./add_link"));
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
render(null, container);
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("submits the selected note when Enter picks an autocomplete suggestion", async () => {
|
||||
act(() => {
|
||||
render(<AddLinkDialog />, container);
|
||||
});
|
||||
|
||||
const showDialog = state.triliumEventHandlers.get("showAddLinkDialog");
|
||||
if (!showDialog) {
|
||||
throw new Error("showAddLinkDialog handler was not registered");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
showDialog({
|
||||
text: "",
|
||||
hasSelection: false,
|
||||
addLink: state.addLinkSpy
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
state.latestNoteAutocompletePropsRef.current.onKeyDownCapture({
|
||||
key: "Enter",
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
isComposing: false
|
||||
});
|
||||
state.latestNoteAutocompletePropsRef.current.onChange({
|
||||
notePath: "root/target-note"
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
state.latestModalPropsRef.current.onHidden();
|
||||
});
|
||||
|
||||
expect(state.addLinkSpy).toHaveBeenCalledWith("root/target-note", null);
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import $ from "jquery";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export interface AddLinkDialogTestState {
|
||||
triliumEventHandlers: Map<string, (payload: any) => void>;
|
||||
latestModalPropsRef: { current: any };
|
||||
latestNoteAutocompletePropsRef: { current: any };
|
||||
addLinkSpy: ReturnType<typeof vi.fn>;
|
||||
logErrorSpy: ReturnType<typeof vi.fn>;
|
||||
showRecentNotesSpy: ReturnType<typeof vi.fn>;
|
||||
setTextSpy: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function createAddLinkDialogTestState(): AddLinkDialogTestState {
|
||||
return {
|
||||
triliumEventHandlers: new Map<string, (payload: any) => void>(),
|
||||
latestModalPropsRef: { current: null as any },
|
||||
latestNoteAutocompletePropsRef: { current: null as any },
|
||||
addLinkSpy: vi.fn(() => Promise.resolve()),
|
||||
logErrorSpy: vi.fn(),
|
||||
showRecentNotesSpy: vi.fn(),
|
||||
setTextSpy: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
export function setupAddLinkDialogMocks(state: AddLinkDialogTestState) {
|
||||
vi.doMock("../../services/i18n", () => ({
|
||||
t: (key: string) => key
|
||||
}));
|
||||
|
||||
vi.doMock("../../services/tree", () => ({
|
||||
default: {
|
||||
getNoteIdFromUrl: (notePath: string) => notePath.split("/").at(-1),
|
||||
getNoteTitle: vi.fn(async () => "Target note")
|
||||
}
|
||||
}));
|
||||
|
||||
vi.doMock("../../services/ws", () => ({
|
||||
logError: state.logErrorSpy
|
||||
}));
|
||||
|
||||
vi.doMock("../../services/note_autocomplete", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
showRecentNotes: state.showRecentNotesSpy,
|
||||
setText: state.setTextSpy
|
||||
}
|
||||
}));
|
||||
|
||||
vi.doMock("../react/react_utils", () => ({
|
||||
refToJQuerySelector: (ref: { current: HTMLInputElement | null }) => ref.current ? $(ref.current) : $()
|
||||
}));
|
||||
|
||||
vi.doMock("../react/hooks", () => ({
|
||||
useTriliumEvent: (name: string, handler: (payload: any) => void) => {
|
||||
state.triliumEventHandlers.set(name, handler);
|
||||
}
|
||||
}));
|
||||
|
||||
vi.doMock("../react/Modal", () => ({
|
||||
default: (props: any) => {
|
||||
state.latestModalPropsRef.current = props;
|
||||
|
||||
if (!props.show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSubmit?.();
|
||||
}}>
|
||||
{props.children}
|
||||
{props.footer}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
vi.doMock("../react/FormGroup", () => ({
|
||||
default: ({ children }: { children: ComponentChildren }) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
vi.doMock("../react/Button", () => ({
|
||||
default: ({ text }: { text: string }) => <button type="submit">{text}</button>
|
||||
}));
|
||||
|
||||
vi.doMock("../react/FormRadioGroup", () => ({
|
||||
default: () => null
|
||||
}));
|
||||
|
||||
vi.doMock("../react/NoteAutocomplete", () => ({
|
||||
default: (props: any) => {
|
||||
state.latestNoteAutocompletePropsRef.current = props;
|
||||
return <input ref={props.inputRef} />;
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { JSX } from "preact";
|
||||
import { useEffect,useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import tree from "../../services/tree";
|
||||
import { logError } from "../../services/ws";
|
||||
import Button from "../react/Button";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { useRef, useState, useEffect } from "preact/hooks";
|
||||
import tree from "../../services/tree";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import { logError } from "../../services/ws";
|
||||
import FormGroup from "../react/FormGroup.js";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
||||
|
||||
@@ -28,8 +26,6 @@ export default function AddLinkDialog() {
|
||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const hasSubmittedRef = useRef(false);
|
||||
const suggestionRef = useRef<Suggestion | null>(null);
|
||||
const submitOnSelectionRef = useRef(false);
|
||||
|
||||
useTriliumEvent("showAddLinkDialog", opts => {
|
||||
setOpts(opts);
|
||||
@@ -89,44 +85,15 @@ export default function AddLinkDialog() {
|
||||
.trigger("select");
|
||||
}
|
||||
|
||||
function submitSelectedLink(selectedSuggestion: Suggestion | null) {
|
||||
submitOnSelectionRef.current = false;
|
||||
hasSubmittedRef.current = Boolean(selectedSuggestion);
|
||||
|
||||
if (!selectedSuggestion) {
|
||||
logError("No link to add.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Insertion logic in onHidden because it needs focus.
|
||||
setShown(false);
|
||||
}
|
||||
|
||||
function onSuggestionChange(nextSuggestion: Suggestion | null) {
|
||||
suggestionRef.current = nextSuggestion;
|
||||
setSuggestion(nextSuggestion);
|
||||
|
||||
if (submitOnSelectionRef.current && nextSuggestion) {
|
||||
submitSelectedLink(nextSuggestion);
|
||||
}
|
||||
}
|
||||
|
||||
function onAutocompleteKeyDownCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitOnSelectionRef.current = true;
|
||||
}
|
||||
|
||||
function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
submitOnSelectionRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
submitSelectedLink(suggestionRef.current);
|
||||
hasSubmittedRef.current = true;
|
||||
|
||||
if (suggestion) {
|
||||
// Insertion logic in onHidden because it needs focus.
|
||||
setShown(false);
|
||||
} else {
|
||||
logError("No link to add.");
|
||||
}
|
||||
}
|
||||
|
||||
const autocompleteRef = useRef<HTMLInputElement>(null);
|
||||
@@ -142,22 +109,19 @@ export default function AddLinkDialog() {
|
||||
onSubmit={onSubmit}
|
||||
onShown={onShown}
|
||||
onHidden={() => {
|
||||
submitOnSelectionRef.current = false;
|
||||
|
||||
// Insert the link.
|
||||
if (hasSubmittedRef.current && suggestionRef.current && opts) {
|
||||
if (hasSubmittedRef.current && suggestion && opts) {
|
||||
hasSubmittedRef.current = false;
|
||||
|
||||
if (suggestionRef.current.notePath) {
|
||||
if (suggestion.notePath) {
|
||||
// Handle note link
|
||||
opts.addLink(suggestionRef.current.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
} else if (suggestionRef.current.externalLink) {
|
||||
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||
} else if (suggestion.externalLink) {
|
||||
// Handle external link
|
||||
opts.addLink(suggestionRef.current.externalLink, linkTitle, true);
|
||||
opts.addLink(suggestion.externalLink, linkTitle, true);
|
||||
}
|
||||
}
|
||||
|
||||
suggestionRef.current = null;
|
||||
setSuggestion(null);
|
||||
setShown(false);
|
||||
}}
|
||||
@@ -166,9 +130,7 @@ export default function AddLinkDialog() {
|
||||
<FormGroup label={t("add_link.note")} name="note">
|
||||
<NoteAutocomplete
|
||||
inputRef={autocompleteRef}
|
||||
onChange={onSuggestionChange}
|
||||
onKeyDownCapture={onAutocompleteKeyDownCapture}
|
||||
onKeyUpCapture={onAutocompleteKeyUpCapture}
|
||||
onChange={setSuggestion}
|
||||
opts={{
|
||||
allowExternalLinks: true,
|
||||
allowCreatingNotes: true
|
||||
|
||||
@@ -108,4 +108,4 @@ async function cloneNotesTo(notePath: string, clonedNoteIds: string[], prefix?:
|
||||
|
||||
toast.showMessage(t("clone_to.note_cloned", { clonedTitle: clonedNote.title, targetTitle: targetNote.title }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import appContext from "../../components/app_context";
|
||||
import commandRegistry from "../../services/command_registry";
|
||||
import { t } from "../../services/i18n";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import shortcutService from "../../services/shortcuts";
|
||||
import Button from "../react/Button";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import shortcutService from "../../services/shortcuts";
|
||||
|
||||
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
|
||||
|
||||
@@ -24,14 +23,14 @@ export default function JumpToNoteDialogComponent() {
|
||||
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
|
||||
const actualText = useRef<string>(initialText);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
async function openDialog(commandMode: boolean) {
|
||||
|
||||
async function openDialog(commandMode: boolean) {
|
||||
let newMode: Mode;
|
||||
let initialText = "";
|
||||
|
||||
if (commandMode) {
|
||||
newMode = "commands";
|
||||
initialText = ">";
|
||||
initialText = ">";
|
||||
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
@@ -59,7 +58,7 @@ export default function JumpToNoteDialogComponent() {
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setShown(false);
|
||||
if (suggestion.notePath) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
|
||||
@@ -84,7 +83,7 @@ export default function JumpToNoteDialogComponent() {
|
||||
$autoComplete
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
|
||||
|
||||
// Add keyboard shortcut for full search
|
||||
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {
|
||||
if (!isCommandMode) {
|
||||
@@ -92,7 +91,7 @@ export default function JumpToNoteDialogComponent() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function showInFullSearch() {
|
||||
try {
|
||||
setShown(false);
|
||||
@@ -127,18 +126,18 @@ export default function JumpToNoteDialogComponent() {
|
||||
setIsCommandMode(text.startsWith(">"));
|
||||
}}
|
||||
onChange={onItemSelected}
|
||||
/>}
|
||||
/>}
|
||||
onShown={onShown}
|
||||
onHidden={() => setShown(false)}
|
||||
footer={!isCommandMode && <Button
|
||||
className="show-in-full-text-button"
|
||||
text={t("jump_to_note.search_button")}
|
||||
footer={!isCommandMode && <Button
|
||||
className="show-in-full-text-button"
|
||||
text={t("jump_to_note.search_button")}
|
||||
keyboardShortcut="Ctrl+Enter"
|
||||
onClick={showInFullSearch}
|
||||
/>}
|
||||
show={shown}
|
||||
>
|
||||
<div className="jump-to-note-results" ref={containerRef} />
|
||||
<div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,4 +75,4 @@ async function moveNotesTo(movedBranchIds: string[] | undefined, parentBranchId:
|
||||
const parentNote = await parentBranch?.getNote();
|
||||
|
||||
toast.showMessage(`${t("move_to.move_success_message")} ${parentNote?.title}`);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
// JQuery here is maintained for compatibility with existing code.
|
||||
interface ShownCallbackData {
|
||||
@@ -41,7 +40,7 @@ export default function PromptDialog() {
|
||||
opts.current = newOpts;
|
||||
setValue(newOpts.defaultValue ?? "");
|
||||
setShown(true);
|
||||
});
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -61,7 +60,7 @@ export default function PromptDialog() {
|
||||
answerRef.current?.select();
|
||||
}}
|
||||
onSubmit={() => {
|
||||
submitValue.current = answerRef.current?.value || value;
|
||||
submitValue.current = value;
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget";
|
||||
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
|
||||
* for protected session or attachment information.
|
||||
*/
|
||||
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole";
|
||||
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
|
||||
|
||||
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
|
||||
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
|
||||
@@ -147,5 +147,11 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||
className: "note-detail-spreadsheet",
|
||||
printable: true,
|
||||
isFullHeight: true
|
||||
},
|
||||
llmChat: {
|
||||
view: () => import("./type_widgets/llm_chat/LlmChat"),
|
||||
className: "note-detail-llm-chat",
|
||||
printable: true,
|
||||
isFullHeight: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import $ from "jquery";
|
||||
import { render } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
initNoteAutocompleteSpy,
|
||||
setTextSpy,
|
||||
clearTextSpy,
|
||||
destroyAutocompleteSpy
|
||||
} = vi.hoisted(() => ({
|
||||
initNoteAutocompleteSpy: vi.fn(($el) => $el),
|
||||
setTextSpy: vi.fn(),
|
||||
clearTextSpy: vi.fn(),
|
||||
destroyAutocompleteSpy: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../../services/i18n", () => ({
|
||||
t: (key: string) => key
|
||||
}));
|
||||
|
||||
vi.mock("../../services/note_autocomplete", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
initNoteAutocomplete: initNoteAutocompleteSpy,
|
||||
setText: setTextSpy,
|
||||
clearText: clearTextSpy,
|
||||
destroyAutocomplete: destroyAutocompleteSpy
|
||||
}
|
||||
}));
|
||||
|
||||
import NoteAutocomplete from "./NoteAutocomplete";
|
||||
|
||||
describe("NoteAutocomplete", () => {
|
||||
let container: HTMLDivElement;
|
||||
let setNoteSpy: ReturnType<typeof vi.fn>;
|
||||
let getSelectedNoteIdSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
setNoteSpy = vi.fn(() => Promise.resolve());
|
||||
getSelectedNoteIdSpy = vi.fn(() => "selected-note-id");
|
||||
|
||||
($.fn as any).setNote = setNoteSpy;
|
||||
($.fn as any).getSelectedNoteId = getSelectedNoteIdSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
render(null, container);
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("syncs text props through the headless helper functions", () => {
|
||||
act(() => {
|
||||
render(<NoteAutocomplete text="hello" />, container);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
|
||||
expect(initNoteAutocompleteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(initNoteAutocompleteSpy.mock.calls[0][0][0]).toBe(input);
|
||||
expect(setTextSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setTextSpy.mock.calls[0][0][0]).toBe(input);
|
||||
expect(setTextSpy).toHaveBeenCalledWith(expect.anything(), "hello");
|
||||
|
||||
act(() => {
|
||||
render(<NoteAutocomplete text="" />, container);
|
||||
});
|
||||
|
||||
expect(clearTextSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs noteId props through the jQuery setNote extension", () => {
|
||||
act(() => {
|
||||
render(<NoteAutocomplete noteId="note-123" />, container);
|
||||
});
|
||||
|
||||
expect(setNoteSpy).toHaveBeenCalledWith("note-123");
|
||||
expect(clearTextSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards autocomplete selection and clear events to consumers", () => {
|
||||
const onChange = vi.fn();
|
||||
const noteIdChanged = vi.fn();
|
||||
|
||||
act(() => {
|
||||
render(<NoteAutocomplete onChange={onChange} noteIdChanged={noteIdChanged} />, container);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
const $input = $(input);
|
||||
const suggestion = { notePath: "root/child-note", noteTitle: "Child note" };
|
||||
|
||||
$input.trigger("autocomplete:noteselected", [suggestion]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(suggestion);
|
||||
expect(noteIdChanged).toHaveBeenCalledWith("child-note");
|
||||
|
||||
input.value = "";
|
||||
$input.trigger("change");
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("forwards onTextChange, onKeyDown and onBlur events", () => {
|
||||
const onTextChange = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
const onBlur = vi.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<NoteAutocomplete
|
||||
onTextChange={onTextChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
/>,
|
||||
container
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
const $input = $(input);
|
||||
|
||||
input.value = "typed text";
|
||||
$input.trigger("input");
|
||||
$input.trigger($.Event("keydown", { originalEvent: new KeyboardEvent("keydown", { key: "Enter" }) }));
|
||||
$input.trigger("blur");
|
||||
|
||||
expect(onTextChange).toHaveBeenCalledWith("typed text");
|
||||
expect(onKeyDown).toHaveBeenCalledWith(expect.any(KeyboardEvent));
|
||||
expect(onBlur).toHaveBeenCalledWith("selected-note-id");
|
||||
});
|
||||
|
||||
it("destroys the autocomplete instance on unmount", () => {
|
||||
act(() => {
|
||||
render(<NoteAutocomplete />, container);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
|
||||
act(() => {
|
||||
render(null, container);
|
||||
});
|
||||
|
||||
expect(destroyAutocompleteSpy).toHaveBeenCalledWith(input);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { JSX,RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||
import type { RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useSyncedRef } from "./hooks";
|
||||
|
||||
interface NoteAutocompleteProps {
|
||||
@@ -17,118 +16,85 @@ interface NoteAutocompleteProps {
|
||||
onChange?: (suggestion: Suggestion | null) => void;
|
||||
onTextChange?: (text: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onKeyDownCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
||||
onKeyUpCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
||||
onBlur?: (newValue: string) => void;
|
||||
noteIdChanged?: (noteId: string) => void;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onKeyDownCapture, onKeyUpCapture, onBlur }: NoteAutocompleteProps) {
|
||||
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) {
|
||||
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const inputEl = ref.current;
|
||||
const $autoComplete = $(inputEl);
|
||||
const $autoComplete = $(ref.current);
|
||||
|
||||
// clear any event listener added in previous invocation of this function
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected")
|
||||
.off("autocomplete:commandselected")
|
||||
|
||||
// The headless autocomplete keeps internal state while the user types, so
|
||||
// initialize it once per mount and drive updates through the helper methods below.
|
||||
note_autocomplete.initNoteAutocomplete($autoComplete, {
|
||||
...opts,
|
||||
container: container?.current
|
||||
});
|
||||
|
||||
return () => {
|
||||
note_autocomplete.destroyAutocomplete(inputEl);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
const inputListener = () => onTextChange?.($autoComplete[0].value);
|
||||
const keyDownListener = (e) => e.originalEvent && onKeyDown?.(e.originalEvent);
|
||||
const blurListener = () => onBlur?.($autoComplete.getSelectedNoteId() ?? "");
|
||||
|
||||
if (onTextChange) {
|
||||
$autoComplete.on("input", inputListener);
|
||||
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
|
||||
}
|
||||
if (onKeyDown) {
|
||||
$autoComplete.on("keydown", keyDownListener);
|
||||
$autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent));
|
||||
}
|
||||
if (onBlur) {
|
||||
$autoComplete.on("blur", blurListener);
|
||||
$autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? ""));
|
||||
}
|
||||
}, [opts, container?.current]);
|
||||
|
||||
return () => {
|
||||
if (onTextChange) {
|
||||
$autoComplete.off("input", inputListener);
|
||||
}
|
||||
if (onKeyDown) {
|
||||
$autoComplete.off("keydown", keyDownListener);
|
||||
}
|
||||
if (onBlur) {
|
||||
$autoComplete.off("blur", blurListener);
|
||||
}
|
||||
};
|
||||
}, [onBlur, onKeyDown, onTextChange]);
|
||||
|
||||
// On change event handlers.
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
if (!(onChange || noteIdChanged)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoCompleteListener = (_e, suggestion) => {
|
||||
onChange?.(suggestion);
|
||||
if (onChange || noteIdChanged) {
|
||||
const autoCompleteListener = (_e, suggestion) => {
|
||||
onChange?.(suggestion);
|
||||
|
||||
if (noteIdChanged) {
|
||||
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
||||
noteIdChanged(noteId);
|
||||
}
|
||||
};
|
||||
const changeListener = (e) => {
|
||||
if (!ref.current?.value) {
|
||||
autoCompleteListener(e, null);
|
||||
}
|
||||
};
|
||||
|
||||
$autoComplete
|
||||
.on("autocomplete:noteselected", autoCompleteListener)
|
||||
.on("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.on("autocomplete:commandselected", autoCompleteListener)
|
||||
.on("change", changeListener);
|
||||
|
||||
return () => {
|
||||
if (noteIdChanged) {
|
||||
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
||||
noteIdChanged(noteId);
|
||||
}
|
||||
};
|
||||
const changeListener = (e) => {
|
||||
if (!ref.current?.value) {
|
||||
autoCompleteListener(e, null);
|
||||
}
|
||||
};
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected", autoCompleteListener)
|
||||
.off("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.off("autocomplete:commandselected", autoCompleteListener)
|
||||
.off("change", changeListener);
|
||||
};
|
||||
}, [onChange, noteIdChanged]);
|
||||
.on("autocomplete:noteselected", autoCompleteListener)
|
||||
.on("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.on("autocomplete:commandselected", autoCompleteListener)
|
||||
.on("change", changeListener);
|
||||
return () => {
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected", autoCompleteListener)
|
||||
.off("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.off("autocomplete:commandselected", autoCompleteListener)
|
||||
.off("change", changeListener);
|
||||
};
|
||||
}
|
||||
}, [opts, container?.current, onChange, noteIdChanged])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
|
||||
if (noteId) {
|
||||
void $autoComplete.setNote(noteId);
|
||||
return;
|
||||
$autoComplete.setNote(noteId);
|
||||
} else if (text) {
|
||||
note_autocomplete.setText($autoComplete, text);
|
||||
} else {
|
||||
$autoComplete.setSelectedNotePath("");
|
||||
$autoComplete.autocomplete("val", "");
|
||||
ref.current.value = "";
|
||||
}
|
||||
|
||||
if (text !== undefined) {
|
||||
if (text) {
|
||||
note_autocomplete.setText($autoComplete, text);
|
||||
} else {
|
||||
note_autocomplete.clearText($autoComplete);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
note_autocomplete.clearText($autoComplete);
|
||||
}, [text, noteId]);
|
||||
|
||||
return (
|
||||
@@ -137,8 +103,6 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
|
||||
id={id}
|
||||
ref={ref}
|
||||
className="note-autocomplete form-control"
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyUpCapture={onKeyUpCapture}
|
||||
placeholder={placeholder ?? t("add_link.search_note")} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -85,7 +85,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
);
|
||||
const isElectron = getIsElectron();
|
||||
const isMac = getIsMac();
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType);
|
||||
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet", "llmChat"].includes(noteType);
|
||||
const isSearchOrBook = ["search", "book"].includes(noteType);
|
||||
const isHelpPage = note.noteId.startsWith("_help");
|
||||
const [syncServerHost] = useTriliumOption("syncServerHost");
|
||||
|
||||
@@ -50,8 +50,9 @@ body.desktop {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note-detail-empty-results .aa-core-panel--contained {
|
||||
border: 0;
|
||||
.note-detail-empty-results .aa-dropdown-menu {
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.empty-tab-search label {
|
||||
|
||||
@@ -25,9 +25,8 @@ function NoteSearch({ ntxId }: { ntxId: string | null }) {
|
||||
|
||||
// Show recent notes.
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
note_autocomplete.showRecentNotes(refToJQuerySelector(autocompleteRef));
|
||||
});
|
||||
const $autoComplete = refToJQuerySelector(autocompleteRef);
|
||||
note_autocomplete.showRecentNotes($autoComplete);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { marked } from "marked";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import type { Citation } from "../../../services/llm_chat.js";
|
||||
import "./LlmChat.css";
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true, // Convert \n to <br>
|
||||
gfm: true // GitHub Flavored Markdown
|
||||
});
|
||||
|
||||
interface StoredMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
citations?: Citation[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: StoredMessage;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatMessage({ message, isStreaming }: Props) {
|
||||
const roleLabel = message.role === "user" ? "You" : "Assistant";
|
||||
|
||||
// Only render markdown for assistant messages
|
||||
const renderedContent = useMemo(() => {
|
||||
if (message.role === "assistant") {
|
||||
return marked.parse(message.content) as string;
|
||||
}
|
||||
return null;
|
||||
}, [message.content, message.role]);
|
||||
|
||||
return (
|
||||
<div className={`llm-chat-message llm-chat-message-${message.role}`}>
|
||||
<div className="llm-chat-message-role">
|
||||
{roleLabel}
|
||||
</div>
|
||||
<div className="llm-chat-message-content">
|
||||
{message.role === "assistant" ? (
|
||||
<>
|
||||
<div
|
||||
className="llm-chat-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent || "" }}
|
||||
/>
|
||||
{isStreaming && <span className="llm-chat-cursor" />}
|
||||
</>
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
</div>
|
||||
{message.citations && message.citations.length > 0 && (
|
||||
<div className="llm-chat-citations">
|
||||
<div className="llm-chat-citations-label">
|
||||
<span className="bx bx-link" />
|
||||
{t("llm_chat.sources")}
|
||||
</div>
|
||||
<ul className="llm-chat-citations-list">
|
||||
{message.citations.map((citation, idx) => (
|
||||
<li key={idx}>
|
||||
<a
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={citation.url}
|
||||
>
|
||||
{citation.title || new URL(citation.url).hostname}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
Normal file
367
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css
Normal file
@@ -0,0 +1,367 @@
|
||||
.llm-chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.llm-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.llm-chat-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.llm-chat-message {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
max-width: 85%;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.llm-chat-message-user {
|
||||
background: var(--accented-background-color);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.llm-chat-message-assistant {
|
||||
background: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.llm-chat-message-role {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-message-content {
|
||||
word-wrap: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Preserve whitespace only for user messages (plain text) */
|
||||
.llm-chat-message-user .llm-chat-message-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.llm-chat-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.1em;
|
||||
background: currentColor;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: llm-chat-blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes llm-chat-blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Tool activity indicator */
|
||||
.llm-chat-tool-activity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--accented-background-color);
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.9rem;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.llm-chat-tool-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--muted-text-color);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: llm-chat-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes llm-chat-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Citations */
|
||||
.llm-chat-citations {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-citations-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list li {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list a {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.llm-chat-citations-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.llm-chat-error {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--danger-background-color, #fee);
|
||||
border: 1px solid var(--danger-border-color, #fcc);
|
||||
color: var(--danger-text-color, #c00);
|
||||
}
|
||||
|
||||
/* Input form */
|
||||
.llm-chat-input-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-chat-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.llm-chat-input {
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
max-height: 200px;
|
||||
resize: vertical;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--main-background-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--main-selection-color);
|
||||
box-shadow: 0 0 0 2px var(--main-selection-color-soft, rgba(0, 123, 255, 0.25));
|
||||
}
|
||||
|
||||
.llm-chat-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.llm-chat-send-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--button-background-color);
|
||||
border: 1px solid var(--button-border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: var(--button-text-color);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.llm-chat-send-btn:hover:not(:disabled) {
|
||||
background: var(--button-hover-background-color, var(--button-background-color));
|
||||
}
|
||||
|
||||
.llm-chat-send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Options row */
|
||||
.llm-chat-options {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.llm-chat-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-text-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.llm-chat-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.llm-chat-toggle .bx {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.llm-chat-toggle:has(input:checked) {
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.llm-chat-toggle:has(input:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Markdown styles */
|
||||
.llm-chat-markdown {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.llm-chat-markdown p {
|
||||
margin: 0 0 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1,
|
||||
.llm-chat-markdown h2,
|
||||
.llm-chat-markdown h3,
|
||||
.llm-chat-markdown h4,
|
||||
.llm-chat-markdown h5,
|
||||
.llm-chat-markdown h6 {
|
||||
margin: 1em 0 0.5em 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1:first-child,
|
||||
.llm-chat-markdown h2:first-child,
|
||||
.llm-chat-markdown h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown h1 { font-size: 1.4em; }
|
||||
.llm-chat-markdown h2 { font-size: 1.25em; }
|
||||
.llm-chat-markdown h3 { font-size: 1.1em; }
|
||||
|
||||
.llm-chat-markdown ul,
|
||||
.llm-chat-markdown ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown code {
|
||||
background: var(--accented-background-color);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: var(--monospace-font-family, monospace);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown pre {
|
||||
background: var(--accented-background-color);
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.llm-chat-markdown blockquote {
|
||||
margin: 0.75em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--main-border-color);
|
||||
background: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.llm-chat-markdown blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown a {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.llm-chat-markdown a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.llm-chat-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.llm-chat-markdown th,
|
||||
.llm-chat-markdown td {
|
||||
border: 1px solid var(--main-border-color);
|
||||
padding: 0.5em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.llm-chat-markdown th {
|
||||
background: var(--accented-background-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.llm-chat-markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.llm-chat-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
249
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx
Normal file
249
apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import { streamChatCompletion, type ChatMessage as ChatMessageData, type Citation } from "../../../services/llm_chat.js";
|
||||
import { useEditorSpacedUpdate } from "../../react/hooks.js";
|
||||
import { TypeWidgetProps } from "../type_widget.js";
|
||||
import ChatMessage from "./ChatMessage.js";
|
||||
import "./LlmChat.css";
|
||||
|
||||
interface StoredMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
citations?: Citation[];
|
||||
}
|
||||
|
||||
interface LlmChatContent {
|
||||
version: 1;
|
||||
messages: StoredMessage[];
|
||||
enableWebSearch?: boolean;
|
||||
}
|
||||
|
||||
export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
|
||||
const [messages, setMessages] = useState<StoredMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [toolActivity, setToolActivity] = useState<string | null>(null);
|
||||
const [pendingCitations, setPendingCitations] = useState<Citation[]>([]);
|
||||
const [enableWebSearch, setEnableWebSearch] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [shouldSave, setShouldSave] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, toolActivity, scrollToBottom]);
|
||||
|
||||
// Use a ref to store the latest messages for getData
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const enableWebSearchRef = useRef(enableWebSearch);
|
||||
enableWebSearchRef.current = enableWebSearch;
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteType: "llmChat",
|
||||
noteContext,
|
||||
getData: () => {
|
||||
// Use refs to get the latest values, avoiding stale closure issues
|
||||
const content: LlmChatContent = {
|
||||
version: 1,
|
||||
messages: messagesRef.current,
|
||||
enableWebSearch: enableWebSearchRef.current
|
||||
};
|
||||
return { content: JSON.stringify(content) };
|
||||
},
|
||||
onContentChange: (content) => {
|
||||
if (!content) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed: LlmChatContent = JSON.parse(content);
|
||||
setMessages(parsed.messages || []);
|
||||
if (typeof parsed.enableWebSearch === "boolean") {
|
||||
setEnableWebSearch(parsed.enableWebSearch);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse LLM chat content:", e);
|
||||
setMessages([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger save after state updates when shouldSave is set
|
||||
useEffect(() => {
|
||||
if (shouldSave) {
|
||||
setShouldSave(false);
|
||||
spacedUpdate.scheduleUpdate();
|
||||
}
|
||||
}, [shouldSave, spacedUpdate]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isStreaming) return;
|
||||
|
||||
setError(null);
|
||||
setToolActivity(null);
|
||||
setPendingCitations([]);
|
||||
|
||||
const userMessage: StoredMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: input.trim(),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const newMessages = [...messages, userMessage];
|
||||
setMessages(newMessages);
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
|
||||
let assistantContent = "";
|
||||
const citations: Citation[] = [];
|
||||
|
||||
const apiMessages: ChatMessageData[] = newMessages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
await streamChatCompletion(
|
||||
apiMessages,
|
||||
{ enableWebSearch },
|
||||
{
|
||||
onChunk: (text) => {
|
||||
assistantContent += text;
|
||||
setStreamingContent(assistantContent);
|
||||
setToolActivity(null); // Clear tool activity when text starts
|
||||
},
|
||||
onToolUse: (toolName, _input) => {
|
||||
const toolLabel = toolName === "web_search"
|
||||
? t("llm_chat.searching_web")
|
||||
: `Using ${toolName}...`;
|
||||
setToolActivity(toolLabel);
|
||||
},
|
||||
onCitation: (citation) => {
|
||||
citations.push(citation);
|
||||
setPendingCitations([...citations]);
|
||||
},
|
||||
onError: (errorMsg) => {
|
||||
console.error("Chat error:", errorMsg);
|
||||
setError(errorMsg);
|
||||
setIsStreaming(false);
|
||||
setToolActivity(null);
|
||||
},
|
||||
onDone: () => {
|
||||
if (assistantContent) {
|
||||
const assistantMessage: StoredMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
citations: citations.length > 0 ? citations : undefined
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
}
|
||||
setStreamingContent("");
|
||||
setPendingCitations([]);
|
||||
setIsStreaming(false);
|
||||
setToolActivity(null);
|
||||
// Trigger save after state updates via useEffect
|
||||
setShouldSave(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [input, isStreaming, messages, enableWebSearch, spacedUpdate]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}, [handleSubmit]);
|
||||
|
||||
const toggleWebSearch = useCallback(() => {
|
||||
setEnableWebSearch(prev => !prev);
|
||||
setShouldSave(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="llm-chat-container">
|
||||
<div className="llm-chat-messages">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="llm-chat-empty">
|
||||
{t("llm_chat.empty_state")}
|
||||
</div>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
{toolActivity && (
|
||||
<div className="llm-chat-tool-activity">
|
||||
<span className="llm-chat-tool-spinner" />
|
||||
{toolActivity}
|
||||
</div>
|
||||
)}
|
||||
{isStreaming && streamingContent && (
|
||||
<ChatMessage
|
||||
message={{
|
||||
id: "streaming",
|
||||
role: "assistant",
|
||||
content: streamingContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
citations: pendingCitations.length > 0 ? pendingCitations : undefined
|
||||
}}
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<div className="llm-chat-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
|
||||
<div className="llm-chat-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="llm-chat-input"
|
||||
value={input}
|
||||
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder={t("llm_chat.placeholder")}
|
||||
disabled={isStreaming}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="llm-chat-send-btn"
|
||||
disabled={isStreaming || !input.trim()}
|
||||
>
|
||||
{isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="llm-chat-options">
|
||||
<label className="llm-chat-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableWebSearch}
|
||||
onChange={toggleWebSearch}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<span className="bx bx-globe" />
|
||||
{t("llm_chat.web_search")}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.0.4",
|
||||
"electron": "41.1.0",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.0.4",
|
||||
"electron": "41.1.0",
|
||||
"fs-extra": "11.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect,test } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import App from "../support/app";
|
||||
|
||||
const TEXT_NOTE_TITLE = "Text notes";
|
||||
@@ -33,7 +32,8 @@ test("Open the note in the correct split pane", async ({ page, context }) => {
|
||||
await noteContent.focus();
|
||||
|
||||
// Click the search result in the second split.
|
||||
await app.getNoteAutocompleteSuggestion(resultsSelector, CODE_NOTE_TITLE).click();
|
||||
await resultsSelector.locator(".aa-suggestion", { hasText: CODE_NOTE_TITLE })
|
||||
.nth(1).click();
|
||||
|
||||
await expect(split2).toContainText(CODE_NOTE_TITLE);
|
||||
});
|
||||
@@ -69,4 +69,4 @@ test("Can directly focus the autocomplete input within the split", async ({ page
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await expect(autocomplete).toBeFocused();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,6 @@ export default class App {
|
||||
readonly currentNoteSplitContent: Locator;
|
||||
readonly sidebar: Locator;
|
||||
private isMobile: boolean = false;
|
||||
private readonly noteAutocompleteSuggestionSelector = ".aa-suggestion:not(.create-note-action):not(.search-notes-action):not(.command-action):not(.external-link-action)";
|
||||
|
||||
constructor(page: Page, context: BrowserContext) {
|
||||
this.page = page;
|
||||
@@ -77,19 +76,12 @@ export default class App {
|
||||
|
||||
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
|
||||
await expect(resultsSelector).toContainText(noteTitle);
|
||||
const suggestionSelector = resultsSelector
|
||||
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
|
||||
.first();
|
||||
const suggestionSelector = resultsSelector.locator(".aa-suggestion")
|
||||
.nth(1); // Select the second one (best candidate), as the first one is "Create a new note"
|
||||
await expect(suggestionSelector).toContainText(noteTitle);
|
||||
await suggestionSelector.click();
|
||||
}
|
||||
|
||||
getNoteAutocompleteSuggestion(resultsContainer: Locator, noteTitle: string) {
|
||||
return resultsContainer
|
||||
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
|
||||
.first();
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
await this.page.locator(".launcher-button.bx-cog").click();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.0-bullseye-slim AS builder
|
||||
FROM node:24.14.1-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:24.14.0-bullseye-slim
|
||||
FROM node:24.14.1-bullseye-slim
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.0-alpine AS builder
|
||||
FROM node:24.14.1-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:24.14.0-alpine
|
||||
FROM node:24.14.1-alpine
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache su-exec shadow
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.0-alpine AS builder
|
||||
FROM node:24.14.1-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:24.14.0-alpine
|
||||
FROM node:24.14.1-alpine
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.14.0-bullseye-slim AS builder
|
||||
FROM node:24.14.1-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:24.14.0-bullseye-slim
|
||||
FROM node:24.14.1-bullseye-slim
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"proxy-nginx-subdir": "docker run --name trilium-nginx-subdir --rm --network=host -v ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"better-sqlite3": "12.8.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"node-html-parser": "7.1.0",
|
||||
@@ -70,7 +71,7 @@
|
||||
"@types/xml2js": "0.4.14",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.14.0",
|
||||
"bindings": "1.5.0",
|
||||
"bootstrap": "5.3.8",
|
||||
"chardet": "2.1.1",
|
||||
@@ -83,13 +84,13 @@
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "5.0.1",
|
||||
"electron": "41.0.4",
|
||||
"electron": "41.1.0",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.2.1",
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.20.0",
|
||||
"express-openid-connect": "2.20.1",
|
||||
"express-rate-limit": "8.3.1",
|
||||
"express-session": "1.19.0",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
@@ -126,7 +127,7 @@
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "8.0.2",
|
||||
"vite": "8.0.3",
|
||||
"ws": "8.20.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.1"
|
||||
|
||||
@@ -55,7 +55,16 @@ export default async function buildApp() {
|
||||
});
|
||||
|
||||
if (!utils.isElectron) {
|
||||
app.use(compression()); // HTTP compression
|
||||
app.use(compression({
|
||||
// Skip compression for SSE endpoints to enable real-time streaming
|
||||
filter: (req, res) => {
|
||||
// Skip compression for LLM chat streaming endpoint
|
||||
if (req.path === "/api/llm-chat/stream") {
|
||||
return false;
|
||||
}
|
||||
return compression.filter(req, res);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let resourcePolicy = config["Network"]["corsResourcePolicy"] as 'same-origin' | 'same-site' | 'cross-origin' | undefined;
|
||||
|
||||
62
apps/server/src/routes/api/llm_chat.ts
Normal file
62
apps/server/src/routes/api/llm_chat.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { getProvider, type LlmMessage, type LlmProviderConfig } from "../../services/llm/index.js";
|
||||
|
||||
interface ChatRequest {
|
||||
messages: LlmMessage[];
|
||||
config?: LlmProviderConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE endpoint for streaming chat completions.
|
||||
*
|
||||
* Response format (Server-Sent Events):
|
||||
* data: {"type":"text","content":"Hello"}
|
||||
* data: {"type":"text","content":" world"}
|
||||
* data: {"type":"done"}
|
||||
*
|
||||
* On error:
|
||||
* data: {"type":"error","error":"Error message"}
|
||||
*/
|
||||
async function streamChat(req: Request, res: Response) {
|
||||
const { messages, config = {} } = req.body as ChatRequest;
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
res.status(400).json({ error: "messages array is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up SSE headers - disable compression and buffering for real-time streaming
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
|
||||
res.setHeader("Content-Encoding", "none"); // Disable compression
|
||||
res.flushHeaders();
|
||||
|
||||
// Mark response as handled to prevent double-handling by apiResultHandler
|
||||
(res as any).triliumResponseHandled = true;
|
||||
|
||||
// Type assertion for flush method (available when compression is used)
|
||||
const flushableRes = res as Response & { flush?: () => void };
|
||||
|
||||
try {
|
||||
const provider = getProvider(config.provider || "anthropic");
|
||||
|
||||
for await (const chunk of provider.streamCompletion(messages, config)) {
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
// Flush immediately to ensure real-time streaming
|
||||
if (typeof flushableRes.flush === "function") {
|
||||
flushableRes.flush();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
res.write(`data: ${JSON.stringify({ type: "error", error: errorMessage })}\n\n`);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
streamChat
|
||||
};
|
||||
@@ -34,6 +34,7 @@ import fontsRoute from "./api/fonts.js";
|
||||
import imageRoute from "./api/image.js";
|
||||
import importRoute from "./api/import.js";
|
||||
import keysRoute from "./api/keys.js";
|
||||
import llmChatRoute from "./api/llm_chat.js";
|
||||
import loginApiRoute from "./api/login.js";
|
||||
import metricsRoute from "./api/metrics.js";
|
||||
import noteMapRoute from "./api/note_map.js";
|
||||
@@ -323,6 +324,9 @@ function register(app: express.Application) {
|
||||
apiRoute(PST, "/api/script/bundle/:noteId", scriptRoute.getBundle);
|
||||
apiRoute(GET, "/api/script/relation/:noteId/:relationName", scriptRoute.getRelationBundles);
|
||||
|
||||
// LLM chat streaming endpoint (SSE)
|
||||
asyncRoute(PST, "/api/llm-chat/stream", [auth.checkApiAuth, csrfMiddleware], llmChatRoute.streamChat, null);
|
||||
|
||||
// no CSRF since this is called from android app
|
||||
route(PST, "/api/sender/login", [loginRateLimiter], loginApiRoute.token, apiResultHandler);
|
||||
asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler);
|
||||
|
||||
@@ -71,27 +71,6 @@ function getAttributeNames(type: string, nameLike: string) {
|
||||
[type, `%${nameLike}%`]
|
||||
);
|
||||
|
||||
// Also include attribute definitions (e.g. 'relation:*' or 'label:*') which are saved as type='label'
|
||||
if (type === "relation" || type === "label") {
|
||||
const prefix = `${type}:`;
|
||||
const defNames = sql.getColumn<string>(
|
||||
/*sql*/`SELECT DISTINCT name
|
||||
FROM attributes
|
||||
WHERE isDeleted = 0
|
||||
AND type = 'label'
|
||||
AND name LIKE ?`,
|
||||
[`${prefix}%${nameLike}%`]
|
||||
);
|
||||
for (const dn of defNames) {
|
||||
if (dn.startsWith(prefix)) {
|
||||
const stripped = dn.substring(prefix.length);
|
||||
if (!names.includes(stripped)) {
|
||||
names.push(stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const attr of BUILTIN_ATTRIBUTES) {
|
||||
if (attr.type === type && attr.name.toLowerCase().includes(nameLike) && !names.includes(attr.name)) {
|
||||
names.push(attr.name);
|
||||
|
||||
26
apps/server/src/services/llm/index.ts
Normal file
26
apps/server/src/services/llm/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { LlmProvider } from "./types.js";
|
||||
import { AnthropicProvider } from "./providers/anthropic.js";
|
||||
|
||||
const providers: Record<string, () => LlmProvider> = {
|
||||
anthropic: () => new AnthropicProvider()
|
||||
// Future providers can be added here
|
||||
};
|
||||
|
||||
let cachedProviders: Record<string, LlmProvider> = {};
|
||||
|
||||
export function getProvider(name: string = "anthropic"): LlmProvider {
|
||||
if (!cachedProviders[name]) {
|
||||
const factory = providers[name];
|
||||
if (!factory) {
|
||||
throw new Error(`Unknown LLM provider: ${name}. Available: ${Object.keys(providers).join(", ")}`);
|
||||
}
|
||||
cachedProviders[name] = factory();
|
||||
}
|
||||
return cachedProviders[name];
|
||||
}
|
||||
|
||||
export function clearProviderCache(): void {
|
||||
cachedProviders = {};
|
||||
}
|
||||
|
||||
export * from "./types.js";
|
||||
110
apps/server/src/services/llm/providers/anthropic.ts
Normal file
110
apps/server/src/services/llm/providers/anthropic.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type { LlmProvider, LlmMessage, LlmStreamChunk, LlmProviderConfig } from "../types.js";
|
||||
|
||||
const DEFAULT_MODEL = "claude-sonnet-4-20250514";
|
||||
const DEFAULT_MAX_TOKENS = 8096;
|
||||
|
||||
export class AnthropicProvider implements LlmProvider {
|
||||
name = "anthropic";
|
||||
private client: Anthropic;
|
||||
|
||||
constructor() {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("ANTHROPIC_API_KEY environment variable is required");
|
||||
}
|
||||
this.client = new Anthropic({ apiKey });
|
||||
}
|
||||
|
||||
async *streamCompletion(
|
||||
messages: LlmMessage[],
|
||||
config: LlmProviderConfig
|
||||
): AsyncIterable<LlmStreamChunk> {
|
||||
const systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
|
||||
const chatMessages = messages.filter(m => m.role !== "system");
|
||||
|
||||
// Build tools array - using 'unknown' assertion for server-side tools
|
||||
// that may not be in the SDK types yet
|
||||
const tools: unknown[] = [];
|
||||
if (config.enableWebSearch) {
|
||||
tools.push({
|
||||
type: "web_search_20250305",
|
||||
name: "web_search",
|
||||
max_uses: 5 // Limit searches per request
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Cast tools to any since server-side tools may not be in SDK types yet
|
||||
const streamParams: Anthropic.Messages.MessageStreamParams = {
|
||||
model: config.model || DEFAULT_MODEL,
|
||||
max_tokens: config.maxTokens || DEFAULT_MAX_TOKENS,
|
||||
system: systemPrompt,
|
||||
messages: chatMessages.map(m => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content
|
||||
}))
|
||||
};
|
||||
|
||||
if (tools.length > 0) {
|
||||
(streamParams as any).tools = tools;
|
||||
}
|
||||
|
||||
const stream = this.client.messages.stream(streamParams);
|
||||
|
||||
for await (const event of stream) {
|
||||
// Handle different event types
|
||||
if (event.type === "content_block_start") {
|
||||
const block = event.content_block;
|
||||
if (block.type === "tool_use") {
|
||||
yield {
|
||||
type: "tool_use",
|
||||
toolName: block.name,
|
||||
toolInput: {} // Input comes in deltas
|
||||
};
|
||||
}
|
||||
} else if (event.type === "content_block_delta") {
|
||||
const delta = event.delta;
|
||||
if (delta.type === "text_delta") {
|
||||
yield { type: "text", content: delta.text };
|
||||
} else if (delta.type === "input_json_delta") {
|
||||
// Tool input is being streamed - we could accumulate it
|
||||
// For now, we already emitted tool_use at start
|
||||
}
|
||||
} else if (event.type === "content_block_stop") {
|
||||
// Content block finished
|
||||
// For server-side tools, results come in subsequent blocks
|
||||
}
|
||||
|
||||
// Handle server-side tool results (for web_search)
|
||||
// These appear as special content blocks in the response
|
||||
if (event.type === "message_delta") {
|
||||
// Check for citations in stop_reason or other metadata
|
||||
}
|
||||
}
|
||||
|
||||
// Get the final message to extract any citations
|
||||
const finalMessage = await stream.finalMessage();
|
||||
for (const block of finalMessage.content) {
|
||||
if (block.type === "text") {
|
||||
// Check for citations in the text block
|
||||
// Anthropic returns citations as part of the content
|
||||
if ("citations" in block && Array.isArray((block as any).citations)) {
|
||||
for (const citation of (block as any).citations) {
|
||||
yield {
|
||||
type: "citation",
|
||||
url: citation.url || citation.source,
|
||||
title: citation.title
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "done" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
yield { type: "error", error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
43
apps/server/src/services/llm/types.ts
Normal file
43
apps/server/src/services/llm/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* LLM Provider types for chat integration.
|
||||
* Provider-agnostic interfaces to support multiple LLM backends.
|
||||
*/
|
||||
|
||||
export interface LlmMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream chunk types for real-time updates.
|
||||
*/
|
||||
export type LlmStreamChunk =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "tool_use"; toolName: string; toolInput: Record<string, unknown> }
|
||||
| { type: "tool_result"; toolName: string; result: string }
|
||||
| { type: "citation"; url: string; title?: string }
|
||||
| { type: "error"; error: string }
|
||||
| { type: "done" };
|
||||
|
||||
export interface LlmProviderConfig {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
systemPrompt?: string;
|
||||
/** Enable web search tool */
|
||||
enableWebSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Stream a chat completion response.
|
||||
* Yields chunks as they arrive from the LLM.
|
||||
*/
|
||||
streamCompletion(
|
||||
messages: LlmMessage[],
|
||||
config: LlmProviderConfig
|
||||
): AsyncIterable<LlmStreamChunk>;
|
||||
}
|
||||
@@ -15,7 +15,8 @@ const noteTypes = [
|
||||
{ type: "doc", defaultMime: "" },
|
||||
{ type: "contentWidget", defaultMime: "" },
|
||||
{ type: "mindMap", defaultMime: "application/json" },
|
||||
{ type: "spreadsheet", defaultMime: "application/json" }
|
||||
{ type: "spreadsheet", defaultMime: "application/json" },
|
||||
{ type: "llmChat", defaultMime: "application/json" }
|
||||
];
|
||||
|
||||
function getDefaultMimeForNoteType(typeName: string) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"preact": "10.29.0",
|
||||
"preact-iso": "2.11.1",
|
||||
"preact-render-to-string": "6.6.6",
|
||||
"react-i18next": "16.6.6"
|
||||
"react-i18next": "17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2.10.5",
|
||||
@@ -22,7 +22,7 @@
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"user-agent-data-types": "0.4.3",
|
||||
"vite": "8.0.2",
|
||||
"vite": "8.0.3",
|
||||
"vitest": "4.1.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.2",
|
||||
"upath": "2.0.1",
|
||||
"vite": "8.0.2",
|
||||
"vite": "8.0.3",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
"vitest": "4.1.2"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,8 @@ export const NOTE_TYPE_ICONS = {
|
||||
doc: "bx bxs-file-doc",
|
||||
contentWidget: "bx bxs-widget",
|
||||
mindMap: "bx bx-sitemap",
|
||||
spreadsheet: "bx bx-table"
|
||||
spreadsheet: "bx bx-table",
|
||||
llmChat: "bx bx-message-square-dots"
|
||||
};
|
||||
|
||||
const FILE_MIME_MAPPINGS = {
|
||||
|
||||
@@ -122,7 +122,8 @@ export const ALLOWED_NOTE_TYPES = [
|
||||
"webView",
|
||||
"code",
|
||||
"mindMap",
|
||||
"spreadsheet"
|
||||
"spreadsheet",
|
||||
"llmChat"
|
||||
] as const;
|
||||
export type NoteType = (typeof ALLOWED_NOTE_TYPES)[number];
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fuse.js": "7.1.0",
|
||||
"katex": "0.16.43",
|
||||
"katex": "0.16.44",
|
||||
"mermaid": "11.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
1030
pnpm-lock.yaml
generated
1030
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user